diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 76b520c46a..1467f5b0b1 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1040,6 +1040,7 @@ export enum FeatureFlagKey { IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED', + IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 01c1825a88..9ac1f9ddc7 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1004,6 +1004,7 @@ export enum FeatureFlagKey { IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED', + IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED', diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts index c512ab2442..448f0a915e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts @@ -13,6 +13,7 @@ import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/gr import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service'; import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service'; import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service'; +import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service'; import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service'; import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service'; import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service'; @@ -35,6 +36,7 @@ const graphqlQueryResolvers = [ GraphqlQueryFindDuplicatesResolverService, GraphqlQueryFindManyResolverService, GraphqlQueryFindOneResolverService, + GraphqlQueryGroupByResolverService, GraphqlQueryMergeManyResolverService, GraphqlQueryRestoreManyResolverService, GraphqlQueryRestoreOneResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts new file mode 100644 index 0000000000..28aab51aa4 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service.ts @@ -0,0 +1,332 @@ +import { Injectable } from '@nestjs/common'; + +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { + GraphqlQueryBaseResolverService, + GraphqlQueryResolverExecutionArgs, +} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service'; +import { + ObjectRecord, + ObjectRecordFilter, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; +import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface'; +import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +@Injectable() +export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolverService< + GroupByResolverArgs, + IGroupByConnection>[] +> { + async resolve( + executionArgs: GraphqlQueryResolverExecutionArgs, + _featureFlagsMap: Record, + ): Promise>[]> { + const { objectMetadataItemWithFieldMaps } = executionArgs.options; + + const groupByFields = this.parseGroupByArgs( + executionArgs.args, + objectMetadataItemWithFieldMaps, + ); + + const aggregateMetrics = Object.keys( + executionArgs.graphqlQuerySelectedFieldsResult.aggregate, + ); + + const mockedGroupByValues = Array.from({ length: 3 }, (_, index) => ({ + groupByDimensionValues: groupByFields.map((groupByField) => + this.getFakeDimensionValues(groupByField, index), + ), + ...aggregateMetrics.reduce( + (acc, aggregateMetric, subIndex) => { + acc[aggregateMetric] = this.getFakeAggregateValues(subIndex + index); + + return acc; + }, + {} as Record, + ), + })); + + return mockedGroupByValues as unknown as IGroupByConnection< + ObjectRecord, + IEdge + >[]; + } + + private parseGroupByArgs( + args: GroupByResolverArgs, + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, + ): { fieldMetadata: FieldMetadataEntity; subFieldName?: string }[] { + const groupByFieldNames = args.groupBy; + + const groupByFields = []; + + for (const fieldNames of groupByFieldNames) { + if (Object.keys(fieldNames).length > 1) { + throw new GraphqlQueryRunnerException( + 'You cannot provide multiple fields in one GroupByInput, split them into multiple GroupByInput', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + for (const fieldName of Object.keys(fieldNames)) { + const fieldMetadataId = + objectMetadataItemWithFieldMaps.fieldIdByName[fieldName]; + const fieldMetadata = + objectMetadataItemWithFieldMaps.fieldsById[fieldMetadataId]; + + if (fieldNames[fieldName] === true) { + groupByFields.push({ + fieldMetadata, + subFieldName: undefined, + }); + continue; + } else if (typeof fieldNames[fieldName] === 'object') { + if (Object.keys(fieldNames[fieldName]).length > 1) { + throw new GraphqlQueryRunnerException( + 'You cannot provide multiple subfields in one GroupByInput, split them into multiple GroupByInput', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + for (const subFieldName of Object.keys(fieldNames[fieldName])) { + if (fieldNames[fieldName][subFieldName] === true) { + groupByFields.push({ + fieldMetadata, + subFieldName, + }); + continue; + } + } + } + } + } + + return groupByFields; + } + + async validate( + _args: GroupByResolverArgs, + _options: WorkspaceQueryRunnerOptions, + ): Promise {} + + //todo : remove these methods after the groupBy logic implementation + private getFakeDimensionValues( + groupByField: { + fieldMetadata: FieldMetadataEntity; + subFieldName?: string; + }, + rank: number, + ): string { + const fakeDimensionValuesByField = { + [FieldMetadataType.TEXT]: [ + 'Text 1', + 'Long long long long and long text 2', + 'Text 3', + ], + [FieldMetadataType.NUMERIC]: [2000, 3000, 4000], + [FieldMetadataType.NUMBER]: [1, 2, 3], + [FieldMetadataType.BOOLEAN]: [true, false, true], + [FieldMetadataType.DATE_TIME]: [ + new Date(2025, 5, 10, 12, 1, 0), + new Date(2025, 5, 10, 12, 1, 0), + new Date(2018, 6, 14, 12, 2, 0), + ], + [FieldMetadataType.DATE]: [ + new Date(2025, 1, 1), + new Date(2025, 1, 2), + new Date(2025, 1, 3), + ], + [FieldMetadataType.UUID]: [ + '00000000-0000-0000-0000-000000000000', + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + ], + [FieldMetadataType.SELECT]: ['Option 1', 'Option 2', 'Option 3'], + [FieldMetadataType.MULTI_SELECT]: [ + ['Tag 1', 'Tag 2'], + ['Tag 3'], + ['Tag 4', 'Tag 5', 'Tag 6'], + ], + [FieldMetadataType.RELATION]: [ + { id: 'relation-1', name: 'Relation 1' }, + { id: 'relation-2', name: 'Relation 2' }, + { id: 'relation-3', name: 'Relation 3' }, + ], + [FieldMetadataType.MORPH_RELATION]: [ + { id: 'morph-1', name: 'Morph Relation 1' }, + { id: 'morph-2', name: 'Morph Relation 2' }, + { id: 'morph-3', name: 'Morph Relation 3' }, + ], + [FieldMetadataType.RATING]: ['RATING_3', 'RATING_4', 'RATING_5'], + [FieldMetadataType.RAW_JSON]: [ + { key: 'value1' }, + { key: 'value2', key2: 'value2' }, + {}, + ], + [FieldMetadataType.ARRAY]: [['value1', 'value2'], ['value3'], []], + [FieldMetadataType.POSITION]: [1, 2, 3], + [FieldMetadataType.TS_VECTOR]: ['vector1', 'vector2', 'vector3'], + [FieldMetadataType.EMAILS]: [ + { + primaryEmail: 'tim@twenty.com', + additionalEmails: [ + 'tim@twenty.com', + 'timapple@twenty.com', + 'johnappletim@twenty.com', + ], + }, + { + primaryEmail: 'jane@twenty.com', + additionalEmails: ['jane@twenty.com', 'jane.doe@twenty.com'], + }, + { + primaryEmail: 'john@twenty.com', + additionalEmails: ['john.doe@twenty.com'], + }, + ], + [FieldMetadataType.PHONES]: [ + { + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneNumber: '789012345', + additionalPhones: [ + { number: '617272323', callingCode: '+33', countryCode: 'FR' }, + ], + }, + { + primaryPhoneCallingCode: '+1', + primaryPhoneCountryCode: 'US', + primaryPhoneNumber: '612345789', + additionalPhones: [ + { number: '123456789', callingCode: '+1', countryCode: 'US' }, + { number: '617272323', callingCode: '+1', countryCode: 'US' }, + ], + }, + { + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneNumber: '123456789', + additionalPhones: [], + }, + ], + [FieldMetadataType.CURRENCY]: [ + { amountMicros: 1000000, currencyCode: 'USD' }, + { amountMicros: 2000000, currencyCode: 'EUR' }, + { amountMicros: 3000000, currencyCode: 'GBP' }, + ], + [FieldMetadataType.LINKS]: [ + { + primaryLinkUrl: 'twenty.com', + primaryLinkLabel: '', + secondaryLinks: [{ url: 'twenty.com', label: 'Twenty' }], + }, + { + primaryLinkUrl: 'github.com/twentyhq/twenty', + primaryLinkLabel: 'Twenty Repo', + secondaryLinks: [{ url: 'twenty.com', label: '' }], + }, + { + primaryLinkUrl: 'react.dev', + primaryLinkLabel: '', + secondaryLinks: [], + }, + ], + [FieldMetadataType.FULL_NAME]: [ + { firstName: 'John', lastName: 'Doe' }, + { firstName: 'Jane', lastName: 'Doe' }, + { firstName: 'John', lastName: 'Smith' }, + ], + [FieldMetadataType.ADDRESS]: [ + { + addressStreet1: '456 Oak Street', + addressStreet2: '', + addressCity: 'Springfield', + addressState: 'California', + addressCountry: 'United States', + addressPostcode: '90210', + addressLat: 34.0522, + addressLng: -118.2437, + }, + { + addressStreet1: '123 Main Street', + addressStreet2: '', + addressCity: 'New York', + addressState: 'New York', + addressCountry: 'United States', + addressPostcode: '10001', + addressLat: 40.7128, + addressLng: -74.006, + }, + { + addressStreet1: '8 rue Saint-Anne', + addressStreet2: '', + addressCity: 'Paris', + addressState: 'Ile-de-France', + addressCountry: 'France', + addressPostcode: '75001', + addressLat: 40.7128, + addressLng: -74.006, + }, + ], + [FieldMetadataType.ACTOR]: [ + { + source: 'IMPORT', + name: 'name', + workspaceMemberId: 'id', + context: { provider: 'GOOGLE' }, + }, + { + source: 'MANUAL', + name: 'name', + workspaceMemberId: 'id', + context: { provider: 'MICROSOFT' }, + }, + { + source: 'WEBHOOK', + name: 'name', + workspaceMemberId: 'id', + context: {}, + }, + ], + [FieldMetadataType.RICH_TEXT_V2]: [ + { + blocknote: '[{"type":"heading","content":"Hello"}]', + markdown: '# Hello', + }, + { + blocknote: '[{"type":"heading","content":"Hello World"}]', + markdown: '# Hello World', + }, + { + blocknote: '[{"type":"heading","content":"Hello Again"}]', + markdown: '# Hello Again', + }, + ], + [FieldMetadataType.RICH_TEXT]: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record; + + const fakeDimensionValues = + fakeDimensionValuesByField[groupByField.fieldMetadata.type][rank % 3]; + + return isDefined(groupByField.subFieldName) + ? fakeDimensionValues[groupByField.subFieldName].toString() + : fakeDimensionValues.toString(); + } + + private getFakeAggregateValues(rank: number) { + const fakeAggregateValues = [39, 20, 56, 88, 2]; + + return fakeAggregateValues[rank % fakeAggregateValues.length]; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts index 70d8b6ab25..e0a9a167e3 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts @@ -12,6 +12,18 @@ export type ObjectRecordFilter = Partial<{ [Property in keyof ObjectRecord]: any; }>; +export type ObjectRecordGroupBy = Array< + ObjectRecordGroupByForAtomicField | ObjectRecordGroupByForCompositeField +>; + +export type ObjectRecordGroupByForAtomicField = Partial<{ + [Property in keyof ObjectRecord]: boolean; +}>; + +export type ObjectRecordGroupByForCompositeField = Partial<{ + [Property in keyof ObjectRecord]: Record; +}>; + export enum OrderByDirection { AscNullsFirst = 'AscNullsFirst', AscNullsLast = 'AscNullsLast', diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface.ts new file mode 100644 index 0000000000..76d175a8c6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface.ts @@ -0,0 +1,9 @@ +import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; +import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; + +export type IGroupByConnection< + T, + CustomEdge extends IEdge = IEdge, +> = IConnection & { + groupByDimensionValues: string[]; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts index a680ab39de..7b3c5e3065 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts @@ -2,6 +2,7 @@ export const RESOLVER_METHOD_NAMES = { FIND_MANY: 'findMany', FIND_ONE: 'findOne', FIND_DUPLICATES: 'findDuplicates', + GROUP_BY: 'groupBy', CREATE_MANY: 'createMany', CREATE_ONE: 'createOne', UPDATE_MANY: 'updateMany', diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts index 3581c37212..0fb6757d37 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts @@ -1,5 +1,6 @@ import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory'; import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory'; +import { GroupByResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory'; import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory'; import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; @@ -29,6 +30,7 @@ export const workspaceResolverBuilderFactories = [ RestoreOneResolverFactory, RestoreManyResolverFactory, MergeManyResolverFactory, + GroupByResolverFactory, ]; export const workspaceResolverBuilderMethodNames = { @@ -36,6 +38,7 @@ export const workspaceResolverBuilderMethodNames = { FindManyResolverFactory.methodName, FindOneResolverFactory.methodName, FindDuplicatesResolverFactory.methodName, + GroupByResolverFactory.methodName, ], mutations: [ CreateManyResolverFactory.methodName, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts new file mode 100644 index 0000000000..f839725b96 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; + +import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; +import { + GroupByResolverArgs, + type Resolver, +} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; + +import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-group-by-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; + +@Injectable() +export class GroupByResolverFactory + implements WorkspaceResolverBuilderFactoryInterface +{ + public static methodName = RESOLVER_METHOD_NAMES.GROUP_BY; + + constructor( + private readonly graphqlQueryRunnerService: GraphqlQueryGroupByResolverService, + ) {} + + create( + context: WorkspaceSchemaBuilderContext, + ): Resolver { + const internalContext = context; + + return async (_source, args, _context, info) => { + const options: WorkspaceQueryRunnerOptions = { + authContext: internalContext.authContext, + info, + objectMetadataMaps: internalContext.objectMetadataMaps, + objectMetadataItemWithFieldMaps: + internalContext.objectMetadataItemWithFieldMaps, + }; + + return await this.graphqlQueryRunnerService.execute( + args, + options, + GroupByResolverFactory.methodName, + ); + }; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 78de478987..4e61a333cb 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -3,6 +3,7 @@ import { type GraphQLFieldResolver } from 'graphql'; import { type ObjectRecord, type ObjectRecordFilter, + type ObjectRecordGroupBy, type ObjectRecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -64,6 +65,13 @@ export interface CreateManyResolverArgs< upsert?: boolean; } +export interface GroupByResolverArgs { + filter?: Filter; + groupBy: ObjectRecordGroupBy; + viewId?: string; + orderBy?: ObjectRecordOrderBy; +} + export interface UpdateOneResolverArgs< Data extends Partial = Partial, > { @@ -131,6 +139,7 @@ export interface WorkspaceResolverBuilderMethods { export type ResolverArgs = | CreateManyResolverArgs | CreateOneResolverArgs + | GroupByResolverArgs | DeleteManyResolverArgs | DeleteOneResolverArgs | DestroyManyResolverArgs diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts index c40063ff40..15722ca67c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts @@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory'; import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory'; import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory'; +import { GroupByResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory'; import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory'; import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory'; import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory'; @@ -54,6 +55,7 @@ export class WorkspaceResolverFactory { private readonly restoreManyResolverFactory: RestoreManyResolverFactory, private readonly destroyManyResolverFactory: DestroyManyResolverFactory, private readonly mergeManyResolverFactory: MergeManyResolverFactory, + private readonly groupByResolverFactory: GroupByResolverFactory, private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService, private readonly featureFlagService: FeatureFlagService, ) {} @@ -81,6 +83,7 @@ export class WorkspaceResolverFactory { ['restoreOne', this.restoreOneResolverFactory], ['updateMany', this.updateManyResolverFactory], ['updateOne', this.updateOneResolverFactory], + ['groupBy', this.groupByResolverFactory], ]); const resolvers: IResolvers = { Query: {}, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts index 8c7e7bda15..cba93ad493 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum.ts @@ -3,4 +3,5 @@ export enum GqlInputTypeDefinitionKind { Update = 'Update', Filter = 'Filter', OrderBy = 'OrderBy', + GroupBy = 'GroupBy', } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/object-type-definition-kind.enum.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/object-type-definition-kind.enum.ts index e6338e4f49..0d9ffab9c2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/object-type-definition-kind.enum.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/enums/object-type-definition-kind.enum.ts @@ -1,4 +1,5 @@ export enum ObjectTypeDefinitionKind { + GroupByConnection = 'GroupByConnection', Connection = 'Connection', Edge = 'Edge', Plain = '', 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 bc0d677817..71934c8f43 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 @@ -10,6 +10,7 @@ import { RelationConnectGqlInputTypeGenerator } from 'src/engine/api/graphql/wor import { CompositeFieldMetadataGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/composite-field-metadata-gql-object-type.generator'; import { ConnectionGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/connection-gql-object-type.generator'; import { EdgeGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/edge-gql-object-type.generator'; +import { GroupByConnectionGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/group-by-connection-gql-object-type.generator'; import { ObjectMetadataGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-gql-object-type.generator'; import { ObjectMetadataWithRelationsGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-with-relations-gql-object-type.generator'; import { MutationTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/root-types/mutation-type.generator'; @@ -32,6 +33,7 @@ export class GqlTypeGenerator { private readonly compositeFieldMetadataGqlInputTypeGenerator: CompositeFieldMetadataGqlInputTypeGenerator, private readonly edgeGqlObjectTypeGenerator: EdgeGqlObjectTypeGenerator, private readonly connectionGqlObjectTypeGenerator: ConnectionGqlObjectTypeGenerator, + private readonly groupByConnectionGqlObjectTypeGenerator: GroupByConnectionGqlObjectTypeGenerator, private readonly objectMetadataWithRelationsGqlObjectTypeGenerator: ObjectMetadataWithRelationsGqlObjectTypeGenerator, private readonly relationConnectGqlInputTypeGenerator: RelationConnectGqlInputTypeGenerator, private readonly queryTypeGenerator: QueryTypeGenerator, @@ -77,6 +79,9 @@ export class GqlTypeGenerator { this.objectMetadataGqlObjectTypeGenerator.buildAndStore(objectMetadata); this.edgeGqlObjectTypeGenerator.buildAndStore(objectMetadata); this.connectionGqlObjectTypeGenerator.buildAndStore(objectMetadata); + this.groupByConnectionGqlObjectTypeGenerator.buildAndStore( + objectMetadata, + ); this.relationConnectGqlInputTypeGenerator.buildAndStore(objectMetadata); this.objectMetadataGqlInputTypeGenerator.buildAndStore(objectMetadata); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/composite-field-metadata-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/composite-field-metadata-gql-input-type.generator.ts index 5cd5181fc3..96052bb4f6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/composite-field-metadata-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/composite-field-metadata-gql-input-type.generator.ts @@ -4,6 +4,7 @@ import { type CompositeType } from 'src/engine/metadata-modules/field-metadata/i import { CompositeFieldMetadataCreateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/composite-field-metadata-create-gql-input-type.generator'; import { CompositeFieldMetadataFilterGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/composite-field-metadata-filter-gql-input-types.generator'; +import { CompositeFieldMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/composite-field-metadata-group-by-gql-input-type.generator'; import { CompositeFieldMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator'; import { CompositeFieldMetadataUpdateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/composite-field-metadata-update-gql-input-type.generator'; @@ -14,6 +15,7 @@ export class CompositeFieldMetadataGqlInputTypeGenerator { private readonly compositeFieldMetadataUpdateGqlInputTypeGenerator: CompositeFieldMetadataUpdateGqlInputTypeGenerator, private readonly compositeFieldMetadataFilterGqlInputTypeGenerator: CompositeFieldMetadataFilterGqlInputTypeGenerator, private readonly compositeFieldMetadataOrderByGqlInputTypeGenerator: CompositeFieldMetadataOrderByGqlInputTypeGenerator, + private readonly compositeFieldMetadataGroupByGqlInputTypeGenerator: CompositeFieldMetadataGroupByGqlInputTypeGenerator, ) {} public buildAndStore(compositeType: CompositeType) { @@ -29,5 +31,8 @@ export class CompositeFieldMetadataGqlInputTypeGenerator { this.compositeFieldMetadataOrderByGqlInputTypeGenerator.buildAndStore( compositeType, ); + this.compositeFieldMetadataGroupByGqlInputTypeGenerator.buildAndStore( + compositeType, + ); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts index 519db7a2a0..2458f5b924 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator.ts @@ -1,25 +1,27 @@ import { Injectable, Logger } from '@nestjs/common'; -import { isDefined } from 'class-validator'; import { - GraphQLEnumType, GraphQLInputFieldConfigMap, GraphQLInputObjectType, - GraphQLScalarType, isEnumType, isInputObjectType, isObjectType, } from 'graphql'; +import { isDefined } from 'twenty-shared/utils'; import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; -import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; +import { + TypeMapperService, + TypeOptions, +} from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; import { computeFieldInputTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-field-input-type-options.util'; import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; import { computeEnumFieldGqlTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-enum-field-gql-type-key.util'; import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util'; import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -79,77 +81,113 @@ export class ObjectMetadataCreateGqlInputTypeGenerator { }, ), }; + } else if (isEnumFieldMetadataType(fieldMetadata.type)) { + generatedFields = this.generateEnumFieldCreateInputType( + objectMetadata, + fieldMetadata, + typeOptions, + ); + } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { + generatedFields = this.generateCompositeFieldCreateInputType( + fieldMetadata, + typeOptions, + ); } else { - let type: - | GraphQLInputObjectType - | GraphQLEnumType - | GraphQLScalarType - | undefined; - - if (isEnumFieldMetadataType(fieldMetadata.type)) { - const key = computeEnumFieldGqlTypeKey( - objectMetadata.nameSingular, - fieldMetadata.name, - ); - - const enumType = this.gqlTypesStorage.getGqlTypeByKey(key); - - if (!isDefined(enumType) || !isEnumType(enumType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - - type = enumType; - } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { - const key = computeCompositeFieldInputTypeKey( - fieldMetadata.type, - GqlInputTypeDefinitionKind.Create, - ); - - const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); - - if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - - type = compositeType; - } else { - type = this.typeMapperService.mapToScalarType( - fieldMetadata.type, - typeOptions, - ); - - if (!isDefined(type) || isObjectType(type)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - } - generatedFields = { - [fieldMetadata.name]: { - type, - description: fieldMetadata.description, - }, - }; + generatedFields = this.generateAtomicFieldCreateInputType( + fieldMetadata, + typeOptions, + ); } + Object.assign(allGeneratedFields, generatedFields); } return allGeneratedFields; } + + private generateEnumFieldCreateInputType( + objectMetadata: ObjectMetadataEntity, + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const key = computeEnumFieldGqlTypeKey( + objectMetadata.nameSingular, + fieldMetadata.name, + ); + + const enumType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(enumType) || !isEnumType(enumType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: enumType, + description: fieldMetadata.description, + }, + }; + } + + private generateCompositeFieldCreateInputType( + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const key = computeCompositeFieldInputTypeKey( + fieldMetadata.type, + GqlInputTypeDefinitionKind.Create, + ); + + const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: compositeType, + description: fieldMetadata.description, + }, + }; + } + + private generateAtomicFieldCreateInputType( + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const type = this.typeMapperService.mapToScalarType( + fieldMetadata.type, + typeOptions, + ); + + if (!isDefined(type) || isObjectType(type)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + type, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type, + description: fieldMetadata.description, + }, + }; + } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts index 301c83802b..bd41575b98 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts @@ -3,7 +3,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { GraphQLInputFieldConfigMap, GraphQLInputObjectType, - GraphQLInputType, isEnumType, isInputObjectType, isObjectType, @@ -12,7 +11,10 @@ import { isDefined } from 'twenty-shared/utils'; import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; -import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; +import { + TypeMapperService, + TypeOptions, +} from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; import { computeFieldInputTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-field-input-type-options.util'; import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; @@ -20,6 +22,7 @@ import { computeEnumFieldGqlTypeKey } from 'src/engine/api/graphql/workspace-sch import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util'; import { createGqlEnumFilterType } from 'src/engine/api/graphql/workspace-schema-builder/utils/create-gql-enum-filter-type.util'; import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -67,7 +70,7 @@ export class ObjectMetadataFilterGqlInputTypeGenerator { const typeOptions = computeFieldInputTypeOptions( fieldMetadata, - GqlInputTypeDefinitionKind.Update, + GqlInputTypeDefinitionKind.Filter, ); let generatedFields; @@ -80,69 +83,22 @@ export class ObjectMetadataFilterGqlInputTypeGenerator { typeOptions, }, ); + } else if (isEnumFieldMetadataType(fieldMetadata.type)) { + generatedFields = this.generateEnumFieldFilterInputType( + objectMetadata, + fieldMetadata, + typeOptions, + ); + } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { + generatedFields = this.generateCompositeFieldFilterInputType( + fieldMetadata, + typeOptions, + ); } else { - let type: GraphQLInputType | undefined; - - if (isEnumFieldMetadataType(fieldMetadata.type)) { - const key = computeEnumFieldGqlTypeKey( - objectMetadata.nameSingular, - fieldMetadata.name, - ); - - const enumType = this.gqlTypesStorage.getGqlTypeByKey(key); - - if (!isDefined(enumType) || !isEnumType(enumType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - - type = createGqlEnumFilterType(enumType); - } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { - const key = computeCompositeFieldInputTypeKey( - fieldMetadata.type, - GqlInputTypeDefinitionKind.Filter, - ); - - const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); - - if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - - type = compositeType; - } else { - type = this.typeMapperService.mapToFilterType( - fieldMetadata.type, - typeOptions, - ); - - if (!isDefined(type) || isObjectType(type)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - } - generatedFields = { - [fieldMetadata.name]: { - type, - description: fieldMetadata.description, - }, - }; + generatedFields = this.generateAtomicFieldFilterInputType( + fieldMetadata, + typeOptions, + ); } Object.assign(allGeneratedFields, generatedFields); } @@ -162,4 +118,92 @@ export class ObjectMetadataFilterGqlInputTypeGenerator { }, }; } + + private generateEnumFieldFilterInputType( + objectMetadata: ObjectMetadataEntity, + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const key = computeEnumFieldGqlTypeKey( + objectMetadata.nameSingular, + fieldMetadata.name, + ); + + const enumType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(enumType) || !isEnumType(enumType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + const type = createGqlEnumFilterType(enumType); + + return { + [fieldMetadata.name]: { + type, + description: fieldMetadata.description, + }, + }; + } + + private generateCompositeFieldFilterInputType( + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const key = computeCompositeFieldInputTypeKey( + fieldMetadata.type, + GqlInputTypeDefinitionKind.Filter, + ); + + const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: compositeType, + description: fieldMetadata.description, + }, + }; + } + + private generateAtomicFieldFilterInputType( + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const type = this.typeMapperService.mapToFilterType( + fieldMetadata.type, + typeOptions, + ); + + if (!isDefined(type) || isObjectType(type)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type, + description: fieldMetadata.description, + }, + }; + } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/composite-field-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/composite-field-metadata-group-by-gql-input-type.generator.ts new file mode 100644 index 0000000000..65f0941f10 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/composite-field-metadata-group-by-gql-input-type.generator.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { + GraphQLBoolean, + GraphQLInputFieldConfigMap, + GraphQLInputObjectType, +} from 'graphql'; + +import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface'; + +import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; +import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; +import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; +import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; +import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util'; +import { pascalCase } from 'src/utils/pascal-case'; + +@Injectable() +export class CompositeFieldMetadataGroupByGqlInputTypeGenerator { + private readonly logger = new Logger( + CompositeFieldMetadataGroupByGqlInputTypeGenerator.name, + ); + constructor( + private readonly gqlTypesStorage: GqlTypesStorage, + private readonly typeMapperService: TypeMapperService, + ) {} + + public buildAndStore(compositeType: CompositeType) { + const key = computeCompositeFieldInputTypeKey( + compositeType.type, + GqlInputTypeDefinitionKind.GroupBy, + ); + + const type = new GraphQLInputObjectType({ + name: `${pascalCase(compositeType.type)}${GqlInputTypeDefinitionKind.GroupBy.toString()}Input`, + fields: this.generateFields(compositeType), + }); + + this.gqlTypesStorage.addGqlType(key, type); + } + + public generateFields( + compositeType: CompositeType, + ): GraphQLInputFieldConfigMap { + const fields: GraphQLInputFieldConfigMap = {}; + + for (const property of compositeType.properties) { + // Relation fields are not supported in composite types + if (isMorphOrRelationFieldMetadataType(property.type)) { + this.logger.error( + 'Relation fields are not supported in composite types', + { compositeType, property }, + ); + + throw new Error('Relation fields are not supported in composite types'); + } + + // Skip hidden fields + if (property.hidden === true || property.hidden === 'input') { + continue; + } + + const type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {}); + + fields[property.name] = { + type, + description: property.description, + }; + } + + return fields; + } +} 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 new file mode 100644 index 0000000000..bea7d37853 --- /dev/null +++ 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 @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { + GraphQLBoolean, + GraphQLInputFieldConfigMap, + GraphQLInputObjectType, + isInputObjectType, +} from 'graphql'; +import { isDefined } from 'twenty-shared/utils'; + +import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; +import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; +import { TypeMapperService } 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 { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; +import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util'; +import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { pascalCase } from 'src/utils/pascal-case'; + +@Injectable() +export class ObjectMetadataGroupByGqlInputTypeGenerator { + private readonly logger = new Logger( + ObjectMetadataGroupByGqlInputTypeGenerator.name, + ); + constructor( + private readonly gqlTypesStorage: GqlTypesStorage, + private readonly relationFieldMetadataGqlInputTypeGenerator: RelationFieldMetadataGqlInputTypeGenerator, + private readonly typeMapperService: TypeMapperService, + ) {} + + public buildAndStore(objectMetadata: ObjectMetadataEntity) { + const inputType = new GraphQLInputObjectType({ + name: `${pascalCase(objectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.GroupBy.toString()}Input`, + description: objectMetadata.description, + fields: () => this.generateFields(objectMetadata), + }) as GraphQLInputObjectType; + + const key = computeObjectMetadataInputTypeKey( + objectMetadata.nameSingular, + GqlInputTypeDefinitionKind.GroupBy, + ); + + this.gqlTypesStorage.addGqlType(key, inputType); + } + + private generateFields( + objectMetadata: ObjectMetadataEntity, + ): GraphQLInputFieldConfigMap { + const allGeneratedFields: GraphQLInputFieldConfigMap = {}; + + for (const fieldMetadata of objectMetadata.fields) { + const generatedField = isFieldMetadataRelationOrMorphRelation( + fieldMetadata, + ) + ? this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldGroupByInputType( + fieldMetadata, + ) + : this.generateField(fieldMetadata); + + Object.assign(allGeneratedFields, generatedField); + } + + return allGeneratedFields; + } + + private generateField(fieldMetadata: FieldMetadataEntity) { + if (isCompositeFieldMetadataType(fieldMetadata.type)) + return this.generateCompositeFieldGroupByInputType(fieldMetadata); + + const type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {}); + + return { + [fieldMetadata.name]: { + type, + description: fieldMetadata.description, + }, + }; + } + + private generateCompositeFieldGroupByInputType( + fieldMetadata: FieldMetadataEntity, + ) { + const key = computeCompositeFieldInputTypeKey( + fieldMetadata.type, + GqlInputTypeDefinitionKind.GroupBy, + ); + + const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: compositeType, + description: fieldMetadata.description, + }, + }; + } +} 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 147729bdc7..aea9aa940f 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 @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ObjectMetadataCreateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator'; import { ObjectMetadataFilterGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator'; +import { ObjectMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator'; import { ObjectMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator'; import { ObjectMetadataUpdateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/update-input/object-metadata-update-gql-input-type.generator'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -13,6 +14,7 @@ export class ObjectMetadataGqlInputTypeGenerator { private readonly objectMetadataUpdateGqlInputTypeGenerator: ObjectMetadataUpdateGqlInputTypeGenerator, private readonly objectMetadataFilterGqlInputTypeGenerator: ObjectMetadataFilterGqlInputTypeGenerator, private readonly objectMetadataOrderByGqlInputTypeGenerator: ObjectMetadataOrderByGqlInputTypeGenerator, + private readonly objectMetadataGroupByGqlInputTypeGenerator: ObjectMetadataGroupByGqlInputTypeGenerator, ) {} public buildAndStore(objectMetadata: ObjectMetadataEntity) { @@ -28,5 +30,8 @@ export class ObjectMetadataGqlInputTypeGenerator { this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore( objectMetadata, ); + this.objectMetadataGroupByGqlInputTypeGenerator.buildAndStore( + objectMetadata, + ); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator.ts index a7efc4c306..50c8665635 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator.ts @@ -13,9 +13,7 @@ import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-sch import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; import { computeCompositeFieldTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-composite-field-type-options.util'; -import { computeCompositeFieldEnumTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-enum-type-key.util'; import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; -import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util'; import { pascalCase } from 'src/utils/pascal-case'; @@ -61,20 +59,13 @@ export class CompositeFieldMetadataOrderByGqlInputTypeGenerator { } // Skip hidden fields - if (property.hidden === true || property.hidden === 'input') { + if (property.hidden === true) { continue; } - const key = computeCompositeFieldEnumTypeKey( - compositeType.type, - property.name, - ); - const typeOptions = computeCompositeFieldTypeOptions(property); - const type = isEnumFieldMetadataType(property.type) - ? this.gqlTypesStorage.getGqlTypeByKey(key) - : this.typeMapperService.mapToOrderByType(property.type); + const type = this.typeMapperService.mapToOrderByType(property.type); if (!isDefined(type) || isObjectType(type)) { const message = `Could not find a GraphQL input type for ${compositeType.type} ${property.name}`; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts index b51a443003..aa161c21fa 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator.ts @@ -1,12 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { - GraphQLEnumType, GraphQLInputFieldConfigMap, GraphQLInputObjectType, - GraphQLInputType, - GraphQLList, - GraphQLScalarType, isEnumType, isInputObjectType, } from 'graphql'; @@ -14,13 +10,17 @@ import { isDefined } from 'twenty-shared/utils'; import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum'; import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; -import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; +import { + TypeMapperService, + TypeOptions, +} from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; import { computeFieldInputTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-field-input-type-options.util'; import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; import { computeEnumFieldGqlTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-enum-field-gql-type-key.util'; import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util'; import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -68,93 +68,119 @@ export class ObjectMetadataOrderByGqlInputTypeGenerator { let generatedFields; if (isFieldMetadataRelationOrMorphRelation(fieldMetadata)) { - generatedFields = { - ...this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldCreateOrUpdateInputType( + generatedFields = + this.relationFieldMetadataGqlInputTypeGenerator.generateSimpleRelationFieldOrderByInputType( { fieldMetadata, typeOptions, }, - ), - ...this.relationFieldMetadataGqlInputTypeGenerator.generateConnectRelationFieldInputType( - { - fieldMetadata, - typeOptions, - }, - ), - }; + ); + } else if (isEnumFieldMetadataType(fieldMetadata.type)) { + generatedFields = this.generateEnumFieldOrderByInputType( + objectMetadata, + fieldMetadata, + typeOptions, + ); + } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { + generatedFields = this.generateCompositeFieldOrderByInputType( + fieldMetadata, + typeOptions, + ); } else { - let type: - | GraphQLInputObjectType - | GraphQLEnumType - | GraphQLScalarType - | GraphQLList - | undefined; - - if (isEnumFieldMetadataType(fieldMetadata.type)) { - const key = computeEnumFieldGqlTypeKey( - objectMetadata.nameSingular, - fieldMetadata.name, - ); - - const enumType = this.gqlTypesStorage.getGqlTypeByKey(key); - - if (!isDefined(enumType) || !isEnumType(enumType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - - type = enumType; - } else if (isCompositeFieldMetadataType(fieldMetadata.type)) { - const key = computeCompositeFieldInputTypeKey( - fieldMetadata.type, - GqlInputTypeDefinitionKind.OrderBy, - ); - - const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); - - if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - - type = compositeType; - } else { - const orderByType = this.typeMapperService.mapToOrderByType( - fieldMetadata.type, - ); - - if (!isDefined(orderByType)) { - const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; - - this.logger.error(message, { - type, - typeOptions, - }); - throw new Error(message); - } - - type = orderByType; - } - generatedFields = { - [fieldMetadata.name]: { - type, - description: fieldMetadata.description, - }, - }; + generatedFields = this.generateAtomicFieldOrderByInputType( + fieldMetadata, + typeOptions, + ); } + Object.assign(allGeneratedFields, generatedFields); } return allGeneratedFields; } + + private generateCompositeFieldOrderByInputType( + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const key = computeCompositeFieldInputTypeKey( + fieldMetadata.type, + GqlInputTypeDefinitionKind.OrderBy, + ); + + const compositeType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(compositeType) || !isInputObjectType(compositeType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: compositeType, + description: fieldMetadata.description, + }, + }; + } + + private generateAtomicFieldOrderByInputType( + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const orderByType = this.typeMapperService.mapToOrderByType( + fieldMetadata.type, + ); + + if (!isDefined(orderByType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: orderByType, + description: fieldMetadata.description, + }, + }; + } + + private generateEnumFieldOrderByInputType( + objectMetadata: ObjectMetadataEntity, + fieldMetadata: FieldMetadataEntity, + typeOptions: TypeOptions, + ) { + const key = computeEnumFieldGqlTypeKey( + objectMetadata.nameSingular, + fieldMetadata.name, + ); + + const enumType = this.gqlTypesStorage.getGqlTypeByKey(key); + + if (!isDefined(enumType) || !isEnumType(enumType)) { + const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`; + + this.logger.error(message, { + fieldMetadata, + typeOptions, + }); + throw new Error(message); + } + + return { + [fieldMetadata.name]: { + type: enumType, + description: fieldMetadata.description, + }, + }; + } } 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 914f089036..7ca02b5848 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,6 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; -import { isObjectType } from 'graphql'; +import { + GraphQLBoolean, + GraphQLInputFieldConfigMap, + isObjectType, +} from 'graphql'; import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -147,6 +151,26 @@ export class RelationFieldMetadataGqlInputTypeGenerator { }; } + public generateSimpleRelationFieldGroupByInputType( + fieldMetadata: FieldMetadataEntity< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >, + ): GraphQLInputFieldConfigMap { + if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) + return {}; + + const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata); + + const type = this.typeMapperService.applyTypeOptions(GraphQLBoolean, {}); + + return { + [joinColumnName]: { + type, + description: fieldMetadata.description, + }, + }; + } + public generateConnectRelationFieldInputType({ fieldMetadata, typeOptions, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/group-by-connection-gql-object-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/group-by-connection-gql-object-type.generator.ts new file mode 100644 index 0000000000..5a04ca4676 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/group-by-connection-gql-object-type.generator.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { GraphQLObjectType, isObjectType } from 'graphql'; +import GraphQLJSON from 'graphql-type-json'; +import { isDefined } from 'twenty-shared/utils'; + +import { ObjectTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/object-type-definition-kind.enum'; +import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; +import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; +import { GraphQLOutputTypeFieldConfigMap } from 'src/engine/api/graphql/workspace-schema-builder/types/graphql-field-config-map.types'; +import { computeObjectMetadataObjectTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-object-type-key.util'; +import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { pascalCase } from 'src/utils/pascal-case'; + +@Injectable() +export class GroupByConnectionGqlObjectTypeGenerator { + private readonly logger = new Logger( + GroupByConnectionGqlObjectTypeGenerator.name, + ); + + constructor( + private readonly typeMapperService: TypeMapperService, + private readonly gqlTypesStorage: GqlTypesStorage, + ) {} + + public buildAndStore(objectMetadata: ObjectMetadataEntity) { + const kind = ObjectTypeDefinitionKind.GroupByConnection; + + const key = computeObjectMetadataObjectTypeKey( + objectMetadata.nameSingular, + kind, + ); + + this.gqlTypesStorage.addGqlType( + key, + new GraphQLObjectType({ + name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`, + description: objectMetadata.description, + fields: () => this.generateFields(objectMetadata), + }), + ); + } + + private generateFields( + objectMetadata: ObjectMetadataEntity, + ): GraphQLOutputTypeFieldConfigMap { + const fields: GraphQLOutputTypeFieldConfigMap = {}; + + const connection = this.gqlTypesStorage.getGqlTypeByKey( + computeObjectMetadataObjectTypeKey( + objectMetadata.nameSingular, + ObjectTypeDefinitionKind.Connection, + ), + ); + + if (!isDefined(connection) || !isObjectType(connection)) { + this.logger.error( + `Connection type for ${objectMetadata.nameSingular} was not found.`, + { + objectMetadata, + }, + ); + + throw new Error( + `Connection type for ${objectMetadata.nameSingular} was not found.`, + ); + } + + Object.assign(fields, connection.toConfig().fields); + + fields.groupByDimensionValues = { + type: this.typeMapperService.applyTypeOptions(GraphQLJSON, { + nullable: false, + }), + }; + + return fields; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/root-types/root-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/root-types/root-type.generator.ts index 78b29af3b2..271a18465a 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/root-types/root-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/root-types/root-type.generator.ts @@ -100,19 +100,20 @@ export class RootTypeGenerator { ); } - const allowedMethodNames = [ + const isMethodReturningArrayObjectType = [ 'updateMany', 'deleteMany', 'createMany', 'findDuplicates', 'restoreMany', 'destroyMany', + 'groupBy', ]; const outputType = this.typeMapperService.applyTypeOptions( objectType, { - isArray: allowedMethodNames.includes(methodName), + isArray: isMethodReturningArrayObjectType.includes(methodName), }, ); @@ -135,6 +136,8 @@ export class RootTypeGenerator { case 'findMany': case 'findDuplicates': return ObjectTypeDefinitionKind.Connection; + case 'groupBy': + return ObjectTypeDefinitionKind.GroupByConnection; default: return ObjectTypeDefinitionKind.Plain; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts index 0b60eff973..0cc1be0aa0 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/type-generators.ts @@ -6,6 +6,8 @@ import { CompositeFieldMetadataCreateGqlInputTypeGenerator } from 'src/engine/ap import { ObjectMetadataCreateGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/create-input/object-metadata-create-gql-input-type.generator'; import { CompositeFieldMetadataFilterGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/composite-field-metadata-filter-gql-input-types.generator'; import { ObjectMetadataFilterGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator'; +import { CompositeFieldMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/composite-field-metadata-group-by-gql-input-type.generator'; +import { ObjectMetadataGroupByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator'; import { ObjectMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator'; import { CompositeFieldMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/composite-field-metadata-order-by-gql-input-type.generator'; import { ObjectMetadataOrderByGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-gql-input-type.generator'; @@ -17,6 +19,7 @@ import { AggregationObjectTypeGenerator } from 'src/engine/api/graphql/workspace import { CompositeFieldMetadataGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/composite-field-metadata-gql-object-type.generator'; import { ConnectionGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/connection-gql-object-type.generator'; import { EdgeGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/edge-gql-object-type.generator'; +import { GroupByConnectionGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/group-by-connection-gql-object-type.generator'; import { ObjectMetadataGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-gql-object-type.generator'; import { ObjectMetadataWithRelationsGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/object-metadata-with-relations-gql-object-type.generator'; import { RelationFieldMetadataGqlObjectTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/object-types/relation-field-metadata-gql-object-type.generator'; @@ -33,6 +36,7 @@ export const workspaceSchemaBuilderTypeGenerators = [ CompositeFieldMetadataUpdateGqlInputTypeGenerator, CompositeFieldMetadataFilterGqlInputTypeGenerator, CompositeFieldMetadataOrderByGqlInputTypeGenerator, + CompositeFieldMetadataGroupByGqlInputTypeGenerator, RelationFieldMetadataGqlInputTypeGenerator, RelationFieldMetadataGqlObjectTypeGenerator, ObjectMetadataGqlInputTypeGenerator, @@ -40,11 +44,13 @@ export const workspaceSchemaBuilderTypeGenerators = [ ObjectMetadataUpdateGqlInputTypeGenerator, ObjectMetadataFilterGqlInputTypeGenerator, ObjectMetadataOrderByGqlInputTypeGenerator, + ObjectMetadataGroupByGqlInputTypeGenerator, CompositeFieldMetadataGqlObjectTypeGenerator, ObjectMetadataGqlObjectTypeGenerator, RelationConnectGqlInputTypeGenerator, ConnectionGqlObjectTypeGenerator, EdgeGqlObjectTypeGenerator, + GroupByConnectionGqlObjectTypeGenerator, ObjectMetadataWithRelationsGqlObjectTypeGenerator, AggregationObjectTypeGenerator, ArgsTypeGenerator, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index 2eb6c9bd03..f842c1090a 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -161,6 +161,27 @@ export const getResolverArgs = ( isNullable: true, }, }; + case 'groupBy': + return { + groupBy: { + kind: GqlInputTypeDefinitionKind.GroupBy, + isNullable: false, + isArray: true, + }, + filter: { + kind: GqlInputTypeDefinitionKind.Filter, + isNullable: true, + }, + orderBy: { + kind: GqlInputTypeDefinitionKind.OrderBy, + isNullable: true, + isArray: true, + }, + viewId: { + type: UUIDScalarType, + isNullable: true, + }, + }; default: throw new Error(`Unknown resolver type: ${type}`); } diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 429f7ac7e9..d0ea0dccd6 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -16,4 +16,5 @@ export enum FeatureFlagKey { IS_WORKFLOW_ITERATOR_ENABLED = 'IS_WORKFLOW_ITERATOR_ENABLED', IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED', IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED', + IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED', } diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index df36dfe5ac..e507d7d76d 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -139,6 +139,7 @@ describe('WorkspaceEntityManager', () => { IS_WORKFLOW_ITERATOR_ENABLED: false, IS_DATABASE_EVENT_TRIGGER_ENABLED: false, IS_CALENDAR_VIEW_ENABLED: false, + IS_GROUP_BY_ENABLED: false, }, eventEmitterService: { emitMutationEvent: jest.fn(), diff --git a/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts b/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts index 6b8a6459d5..a4df3965c2 100644 --- a/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts +++ b/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts @@ -44,6 +44,9 @@ export const getResolverName = ( case 'mergeMany': return `merge${pascalCase(objectMetadata.namePlural)}`; + case 'groupBy': + return `${camelCase(objectMetadata.namePlural)}GroupBy`; + default: throw new Error(`Unknown resolver type: ${type}`); } diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 27459b70a1..5197596f72 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -75,6 +75,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_GROUP_BY_ENABLED, + workspaceId: workspaceId, + value: false, + }, ]) .execute(); };