From 511150a2d320fdbe6762baa19ac24f5cee08bf5d Mon Sep 17 00:00:00 2001 From: Weiko Date: Fri, 4 Oct 2024 11:58:33 +0200 Subject: [PATCH] Refactor graphql query runner and add mutation resolvers (#7418) Fixes https://github.com/twentyhq/twenty/issues/6859 This PR adds all the remaining resolvers for - updateOne/updateMany - createOne/createMany - deleteOne/deleteMany - destroyOne - restoreMany Also - refactored the graphql-query-runner to be able to add other resolvers without too much boilerplate. - add missing events that were not sent anymore as well as webhooks - make resolver injectable so they can inject other services as well - use objectMetadataMap from cache instead of computing it multiple time - various fixes (mutation not correctly parsing JSON, relationHelper fetching data with empty ids set, ...) Next steps: - Wrapping query builder to handle DB events properly - Move webhook emitters to db event listener - Add pagination where it's missing (findDuplicates, nested relations, etc...) --- .../1726848397026-addTypeOrmMetadata.ts | 0 .../migrations/1725893697807-addIndexType.ts | 8 +- .../graphql-query-resolver.factory.ts | 51 ++ .../graphql-query-filter-condition.parser.ts | 2 +- .../graphql-query-filter-field.parser.ts | 3 - .../graphql-query.parser.ts | 14 +- .../graphql-query-runner.module.ts | 29 +- .../graphql-query-runner.service.ts | 565 +++++++++--------- ...t-records-to-graphql-connection.helper.ts} | 2 +- .../process-nested-relations.helper.ts | 276 +++++---- .../interfaces/resolver-service.interface.ts | 12 + ...phql-query-create-many-resolver.service.ts | 103 +++- ...phql-query-destroy-one-resolver.service.ts | 57 +- ...-query-find-duplicates-resolver.service.ts | 214 +++++++ ...raphql-query-find-many-resolver.service.ts | 128 ++-- ...graphql-query-find-one-resolver.service.ts | 77 +-- .../graphql-query-search-resolver.service.ts | 84 ++- ...phql-query-update-many-resolver.service.ts | 116 ++++ ...aphql-query-update-one-resolver.service.ts | 123 ++++ .../services/api-event-emitter.service.ts | 137 +++++ .../utils/cursors.util.ts | 23 + .../query-runner-option.interface.ts | 6 + .../types/workspace-query-hook.type.ts | 5 +- .../factories/create-many-resolver.factory.ts | 5 +- .../factories/create-one-resolver.factory.ts | 13 +- .../factories/delete-many-resolver.factory.ts | 24 +- .../factories/delete-one-resolver.factory.ts | 24 +- .../destroy-many-resolver.factory.ts | 12 +- .../factories/destroy-one-resolver.factory.ts | 9 +- .../find-duplicates-resolver.factory.ts | 30 +- .../factories/find-many-resolver.factory.ts | 5 +- .../factories/find-one-resolver.factory.ts | 5 +- .../restore-many-resolver.factory.ts | 30 +- .../factories/search-resolver-factory.ts | 9 +- .../factories/update-many-resolver.factory.ts | 24 +- .../factories/update-one-resolver.factory.ts | 24 +- .../workspace-resolver.factory.ts | 10 +- ...kspace-schema-builder-context.interface.ts | 8 +- .../api/graphql/workspace-schema.factory.ts | 1 + .../duplicate/duplicate.service.ts | 80 +-- .../index-metadata/index-metadata.entity.ts | 2 +- .../twenty-orm/utils/format-data.util.ts | 97 +-- .../twenty-orm/utils/format-result.util.ts | 24 + 43 files changed, 1696 insertions(+), 775 deletions(-) rename packages/twenty-server/src/database/typeorm/{metadata => core}/migrations/1726848397026-addTypeOrmMetadata.ts (100%) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts rename packages/twenty-server/src/engine/api/graphql/graphql-query-runner/{orm-mappers/object-records-to-graphql-connection.mapper.ts => helpers/object-records-to-graphql-connection.helper.ts} (99%) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1726848397026-addTypeOrmMetadata.ts similarity index 100% rename from packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts rename to packages/twenty-server/src/database/typeorm/core/migrations/1726848397026-addTypeOrmMetadata.ts diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts index 41edac1a5..59a182862 100644 --- a/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts @@ -5,12 +5,12 @@ export class AddIndexType1725893697807 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TYPE metadata."indextype_enum" AS ENUM ('BTREE', 'GIN')`, + `CREATE TYPE "metadata"."indexMetadata_indextype_enum" AS ENUM('BTREE', 'GIN')`, ); await queryRunner.query(` ALTER TABLE metadata."indexMetadata" - ADD COLUMN "indexType" metadata."indextype_enum" NOT NULL DEFAULT 'BTREE'; + ADD COLUMN "indexType" metadata."indexMetadata_indextype_enum" NOT NULL DEFAULT 'BTREE'; `); } @@ -19,6 +19,8 @@ export class AddIndexType1725893697807 implements MigrationInterface { ALTER TABLE metadata."indexMetadata" DROP COLUMN "indexType" `); - await queryRunner.query(`DROP TYPE metadata."indextype_enum"`); + await queryRunner.query( + `DROP TYPE metadata."indexMetadata_indextype_enum"`, + ); } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts new file mode 100644 index 000000000..ca2506d18 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; +import { + ResolverArgs, + WorkspaceResolverBuilderMethodNames, +} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service'; +import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service'; +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 { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service'; +import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service'; +import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service'; + +@Injectable() +export class GraphqlQueryResolverFactory { + constructor(private moduleRef: ModuleRef) {} + + public getResolver( + operationName: WorkspaceResolverBuilderMethodNames, + ): ResolverService { + switch (operationName) { + case 'findOne': + return this.moduleRef.get(GraphqlQueryFindOneResolverService); + case 'findMany': + return this.moduleRef.get(GraphqlQueryFindManyResolverService); + case 'findDuplicates': + return this.moduleRef.get(GraphqlQueryFindDuplicatesResolverService); + case 'search': + return this.moduleRef.get(GraphqlQuerySearchResolverService); + case 'createOne': + case 'createMany': + return this.moduleRef.get(GraphqlQueryCreateManyResolverService); + case 'destroyOne': + return this.moduleRef.get(GraphqlQueryDestroyOneResolverService); + case 'updateOne': + case 'deleteOne': + return this.moduleRef.get(GraphqlQueryUpdateOneResolverService); + case 'updateMany': + case 'deleteMany': + case 'restoreMany': + return this.moduleRef.get(GraphqlQueryUpdateManyResolverService); + default: + throw new Error(`Unsupported operation: ${operationName}`); + } + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts index 093364db8..21f9bdbdc 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts @@ -25,7 +25,7 @@ export class GraphqlQueryFilterConditionParser { public parse( queryBuilder: SelectQueryBuilder, objectNameSingular: string, - filter: RecordFilter, + filter: Partial, ): SelectQueryBuilder { if (!filter || Object.keys(filter).length === 0) { return queryBuilder; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index fe37c4d44..920fa01c5 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -58,7 +58,6 @@ export class GraphqlQueryFilterFieldParser { } const { sql, params } = this.computeWhereConditionParts( - fieldMetadata, operator, objectNameSingular, key, @@ -73,7 +72,6 @@ export class GraphqlQueryFilterFieldParser { } private computeWhereConditionParts( - fieldMetadata: FieldMetadataInterface, operator: string, objectNameSingular: string, key: string, @@ -185,7 +183,6 @@ export class GraphqlQueryFilterFieldParser { ); const { sql, params } = this.computeWhereConditionParts( - fieldMetadata, operator, objectNameSingular, fullFieldName, 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 d83667211..0aa047fc3 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 @@ -9,7 +9,6 @@ import { RecordFilter, RecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; -import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser'; import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; @@ -17,6 +16,7 @@ import { GraphqlQuerySelectedFieldsParser } from 'src/engine/api/graphql/graphql import { FieldMetadataMap, ObjectMetadataMap, + ObjectMetadataMapItem, } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; export class GraphqlQueryParser { @@ -39,10 +39,10 @@ export class GraphqlQueryParser { ); } - applyFilterToBuilder( + public applyFilterToBuilder( queryBuilder: SelectQueryBuilder, objectNameSingular: string, - recordFilter: RecordFilter, + recordFilter: Partial, ): SelectQueryBuilder { return this.filterConditionParser.parse( queryBuilder, @@ -51,7 +51,7 @@ export class GraphqlQueryParser { ); } - applyDeletedAtToBuilder( + public applyDeletedAtToBuilder( queryBuilder: SelectQueryBuilder, recordFilter: RecordFilter, ): SelectQueryBuilder { @@ -88,7 +88,7 @@ export class GraphqlQueryParser { return false; }; - applyOrderToBuilder( + public applyOrderToBuilder( queryBuilder: SelectQueryBuilder, orderBy: RecordOrderBy, objectNameSingular: string, @@ -103,8 +103,8 @@ export class GraphqlQueryParser { return queryBuilder.orderBy(parsedOrderBys as OrderByCondition); } - parseSelectedFields( - parentObjectMetadata: ObjectMetadataInterface, + public parseSelectedFields( + parentObjectMetadata: ObjectMetadataMapItem, graphqlSelectedFields: Partial>, ): { select: Record; relations: Record } { const parentFields = 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 28cb362ca..8c689ff8a 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts @@ -1,16 +1,43 @@ import { Module } from '@nestjs/common'; +import { GraphqlQueryResolverFactory } from 'src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory'; import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; +import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service'; +import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service'; +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 { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service'; +import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service'; +import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service'; +import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module'; import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; + +const graphqlQueryResolvers = [ + GraphqlQueryFindOneResolverService, + GraphqlQueryFindManyResolverService, + GraphqlQueryFindDuplicatesResolverService, + GraphqlQueryCreateManyResolverService, + GraphqlQueryDestroyOneResolverService, + GraphqlQueryUpdateOneResolverService, + GraphqlQueryUpdateManyResolverService, + GraphqlQuerySearchResolverService, +]; + @Module({ imports: [ WorkspaceQueryHookModule, WorkspaceQueryRunnerModule, FeatureFlagModule, ], - providers: [GraphqlQueryRunnerService], + providers: [ + GraphqlQueryRunnerService, + GraphqlQueryResolverFactory, + ApiEventEmitterService, + ...graphqlQueryResolvers, + ], exports: [GraphqlQueryRunnerService], }) export class GraphqlQueryRunnerModule {} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts index 8c866695b..4e3475927 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts @@ -6,180 +6,90 @@ import { RecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; +import { IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { CreateManyResolverArgs, CreateOneResolverArgs, + DeleteManyResolverArgs, + DeleteOneResolverArgs, DestroyOneResolverArgs, + FindDuplicatesResolverArgs, FindManyResolverArgs, FindOneResolverArgs, + ResolverArgs, ResolverArgsType, + RestoreManyResolverArgs, SearchResolverArgs, + UpdateManyResolverArgs, + UpdateOneResolverArgs, + WorkspaceResolverBuilderMethodNames, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; -import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service'; -import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-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 { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service'; +import { GraphqlQueryResolverFactory } from 'src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory'; +import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory'; import { CallWebhookJobsJob, CallWebhookJobsJobData, CallWebhookJobsJobOperation, } from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook-jobs.job'; -import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; -import { - WorkspaceQueryRunnerException, - WorkspaceQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; -import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { LogExecutionTime } from 'src/engine/decorators/observability/log-execution-time.decorator'; -import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; +import { capitalize } from 'src/utils/capitalize'; @Injectable() export class GraphqlQueryRunnerService { constructor( - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - private readonly featureFlagService: FeatureFlagService, private readonly workspaceQueryHookService: WorkspaceQueryHookService, private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory, - private readonly workspaceEventEmitter: WorkspaceEventEmitter, @InjectMessageQueue(MessageQueue.webhookQueue) private readonly messageQueueService: MessageQueueService, + private readonly graphqlQueryResolverFactory: GraphqlQueryResolverFactory, + private readonly apiEventEmitterService: ApiEventEmitterService, ) {} + /** QUERIES */ + @LogExecutionTime() - async findOne< - ObjectRecord extends IRecord = IRecord, - Filter extends RecordFilter = RecordFilter, - >( + async findOne( args: FindOneResolverArgs, options: WorkspaceQueryRunnerOptions, - ): Promise { - const graphqlQueryFindOneResolverService = - new GraphqlQueryFindOneResolverService(this.twentyORMGlobalManager); - - const { authContext, objectMetadataItem } = options; - - if (!args.filter || Object.keys(args.filter).length === 0) { - throw new WorkspaceQueryRunnerException( - 'Missing filter argument', - WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } - - const hookedArgs = - await this.workspaceQueryHookService.executePreQueryHooks( - authContext, - objectMetadataItem.nameSingular, - 'findOne', - args, - ); - - const computedArgs = (await this.queryRunnerArgsFactory.create( - hookedArgs, + ): Promise { + return this.executeQuery, ObjectRecord>( + 'findOne', + args, options, - ResolverArgsType.FindOne, - )) as FindOneResolverArgs; - - return graphqlQueryFindOneResolverService.findOne(computedArgs, options); + ); } @LogExecutionTime() async findMany< - ObjectRecord extends IRecord = IRecord, - Filter extends RecordFilter = RecordFilter, - OrderBy extends RecordOrderBy = RecordOrderBy, + ObjectRecord extends IRecord, + Filter extends RecordFilter, + OrderBy extends RecordOrderBy, >( args: FindManyResolverArgs, options: WorkspaceQueryRunnerOptions, - ): Promise> { - const graphqlQueryFindManyResolverService = - new GraphqlQueryFindManyResolverService(this.twentyORMGlobalManager); - - const { authContext, objectMetadataItem } = options; - - const hookedArgs = - await this.workspaceQueryHookService.executePreQueryHooks( - authContext, - objectMetadataItem.nameSingular, - 'findMany', - args, - ); - - const computedArgs = (await this.queryRunnerArgsFactory.create( - hookedArgs, - options, - ResolverArgsType.FindMany, - )) as FindManyResolverArgs; - - return graphqlQueryFindManyResolverService.findMany(computedArgs, options); + ): Promise>> { + return this.executeQuery< + FindManyResolverArgs, + IConnection> + >('findMany', args, options); } @LogExecutionTime() - async createOne( - args: CreateOneResolverArgs>, + async findDuplicates( + args: FindDuplicatesResolverArgs>, options: WorkspaceQueryRunnerOptions, - ): Promise { - const graphqlQueryCreateManyResolverService = - new GraphqlQueryCreateManyResolverService(this.twentyORMGlobalManager); - - const { authContext, objectMetadataItem } = options; - - assertMutationNotOnRemoteObject(objectMetadataItem); - - if (args.data.id) { - assertIsValidUuid(args.data.id); - } - - const createManyArgs = { - data: [args.data], - upsert: args.upsert, - } as CreateManyResolverArgs; - - const hookedArgs = - await this.workspaceQueryHookService.executePreQueryHooks( - authContext, - objectMetadataItem.nameSingular, - 'createMany', - createManyArgs, - ); - - const computedArgs = (await this.queryRunnerArgsFactory.create( - hookedArgs, - options, - ResolverArgsType.CreateMany, - )) as CreateManyResolverArgs; - - const results = (await graphqlQueryCreateManyResolverService.createMany( - computedArgs, - options, - )) as ObjectRecord[]; - - await this.triggerWebhooks( - results, - CallWebhookJobsJobOperation.create, - options, - ); - - this.emitCreateEvents( - results, - authContext, - objectMetadataItem, - ); - - return results?.[0] as ObjectRecord; + ): Promise[]> { + return this.executeQuery< + FindDuplicatesResolverArgs>, + IConnection[] + >('findDuplicates', args, options); } @LogExecutionTime() @@ -187,104 +97,286 @@ export class GraphqlQueryRunnerService { args: SearchResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise> { - const graphqlQuerySearchResolverService = - new GraphqlQuerySearchResolverService( - this.twentyORMGlobalManager, - this.featureFlagService, - ); + return this.executeQuery>( + 'search', + args, + options, + ); + } - return graphqlQuerySearchResolverService.search(args, options); + /** MUTATIONS */ + + @LogExecutionTime() + async createOne( + args: CreateOneResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const results = await this.executeQuery< + CreateManyResolverArgs>, + ObjectRecord[] + >('createMany', { data: [args.data], upsert: args.upsert }, options); + + // TODO: emitCreateEvents should be moved to the ORM layer + if (results) { + this.apiEventEmitterService.emitCreateEvents( + results, + options.authContext, + options.objectMetadataItem, + ); + } + + return results[0]; } @LogExecutionTime() - async createMany( + async createMany( args: CreateManyResolverArgs>, options: WorkspaceQueryRunnerOptions, - ): Promise { - const graphqlQueryCreateManyResolverService = - new GraphqlQueryCreateManyResolverService(this.twentyORMGlobalManager); + ): Promise { + const results = await this.executeQuery< + CreateManyResolverArgs>, + ObjectRecord[] + >('createMany', args, options); + if (results) { + this.apiEventEmitterService.emitCreateEvents( + results, + options.authContext, + options.objectMetadataItem, + ); + } + + return results; + } + + @LogExecutionTime() + public async updateOne( + args: UpdateOneResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const existingRecord = await this.executeQuery< + FindOneResolverArgs, + ObjectRecord + >( + 'findOne', + { + filter: { id: { eq: args.id } }, + }, + options, + ); + + const result = await this.executeQuery< + UpdateOneResolverArgs>, + ObjectRecord + >('updateOne', args, options); + + this.apiEventEmitterService.emitUpdateEvents( + [existingRecord], + [result], + Object.keys(args.data), + options.authContext, + options.objectMetadataItem, + ); + + return result; + } + + @LogExecutionTime() + public async updateMany( + args: UpdateManyResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const existingRecords = await this.executeQuery< + FindManyResolverArgs, + IConnection> + >( + 'findMany', + { + filter: args.filter, + }, + options, + ); + + const result = await this.executeQuery< + UpdateManyResolverArgs>, + ObjectRecord[] + >('updateMany', args, options); + + this.apiEventEmitterService.emitUpdateEvents( + existingRecords.edges.map((edge) => edge.node), + result, + Object.keys(args.data), + options.authContext, + options.objectMetadataItem, + ); + + return result; + } + + @LogExecutionTime() + public async deleteOne( + args: DeleteOneResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const result = await this.executeQuery< + UpdateOneResolverArgs>, + ObjectRecord + >( + 'deleteOne', + { + id: args.id, + data: { deletedAt: new Date() } as Partial, + }, + options, + ); + + this.apiEventEmitterService.emitDeletedEvents( + [result], + options.authContext, + options.objectMetadataItem, + ); + + return result; + } + + @LogExecutionTime() + public async deleteMany( + args: DeleteManyResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const result = await this.executeQuery< + UpdateManyResolverArgs>, + ObjectRecord[] + >( + 'deleteMany', + { + filter: args.filter, + + data: { deletedAt: new Date() } as Partial, + }, + options, + ); + + this.apiEventEmitterService.emitDeletedEvents( + result, + options.authContext, + options.objectMetadataItem, + ); + + return result; + } + + @LogExecutionTime() + async destroyOne( + args: DestroyOneResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const result = await this.executeQuery< + DestroyOneResolverArgs, + ObjectRecord + >('destroyOne', args, options); + + this.apiEventEmitterService.emitDestroyEvents( + [result], + options.authContext, + options.objectMetadataItem, + ); + + return result; + } + + @LogExecutionTime() + public async restoreMany( + args: RestoreManyResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const result = await this.executeQuery< + UpdateManyResolverArgs>, + ObjectRecord + >( + 'restoreMany', + { + filter: args.filter, + data: { deletedAt: null } as Partial, + }, + options, + ); + + return result; + } + + private async executeQuery( + operationName: WorkspaceResolverBuilderMethodNames, + args: Input, + options: WorkspaceQueryRunnerOptions, + ): Promise { const { authContext, objectMetadataItem } = options; - assertMutationNotOnRemoteObject(objectMetadataItem); + const resolver = + this.graphqlQueryResolverFactory.getResolver(operationName); - args.data.forEach((record) => { - if (record?.id) { - assertIsValidUuid(record.id); - } - }); + await resolver.validate(args, options); const hookedArgs = await this.workspaceQueryHookService.executePreQueryHooks( authContext, objectMetadataItem.nameSingular, - 'createMany', + operationName, args, ); - const computedArgs = (await this.queryRunnerArgsFactory.create( + const computedArgs = await this.queryRunnerArgsFactory.create( hookedArgs, options, - ResolverArgsType.CreateMany, - )) as CreateManyResolverArgs; + ResolverArgsType[capitalize(operationName)], + ); - const results = (await graphqlQueryCreateManyResolverService.createMany( - computedArgs, - options, - )) as ObjectRecord[]; + const results = await resolver.resolve(computedArgs as Input, options); await this.workspaceQueryHookService.executePostQueryHooks( authContext, objectMetadataItem.nameSingular, - 'createMany', - results, + operationName, + Array.isArray(results) ? results : [results], ); - await this.triggerWebhooks( - results, - CallWebhookJobsJobOperation.create, - options, - ); + const jobOperation = this.operationNameToJobOperation(operationName); - this.emitCreateEvents( - results, - authContext, - objectMetadataItem, - ); + if (jobOperation) { + await this.triggerWebhooks(results, jobOperation, options); + } return results; } - private emitCreateEvents( - records: BaseRecord[], - authContext: AuthContext, - objectMetadataItem: ObjectMetadataInterface, - ) { - this.workspaceEventEmitter.emit( - `${objectMetadataItem.nameSingular}.created`, - records.map( - (record) => - ({ - userId: authContext.user?.id, - recordId: record.id, - objectMetadata: objectMetadataItem, - properties: { - after: record, - }, - }) satisfies ObjectRecordCreateEvent, - ), - authContext.workspace.id, - ); + private operationNameToJobOperation( + operationName: WorkspaceResolverBuilderMethodNames, + ): CallWebhookJobsJobOperation | undefined { + switch (operationName) { + case 'createOne': + case 'createMany': + return CallWebhookJobsJobOperation.create; + case 'updateOne': + case 'updateMany': + case 'restoreMany': + return CallWebhookJobsJobOperation.update; + case 'deleteOne': + case 'deleteMany': + return CallWebhookJobsJobOperation.delete; + case 'destroyOne': + return CallWebhookJobsJobOperation.destroy; + default: + return undefined; + } } - private async triggerWebhooks( - jobsData: Record[] | undefined, + private async triggerWebhooks( + jobsData: T[] | undefined, operation: CallWebhookJobsJobOperation, options: WorkspaceQueryRunnerOptions, - ) { - if (!Array.isArray(jobsData)) { - return; - } + ): Promise { + if (!jobsData || !Array.isArray(jobsData)) return; + jobsData.forEach((jobData) => { this.messageQueueService.add( CallWebhookJobsJob.name, @@ -298,99 +390,4 @@ export class GraphqlQueryRunnerService { ); }); } - - @LogExecutionTime() - async destroyOne( - args: DestroyOneResolverArgs, - options: WorkspaceQueryRunnerOptions, - ): Promise { - const graphqlQueryDestroyOneResolverService = - new GraphqlQueryDestroyOneResolverService(this.twentyORMGlobalManager); - - const { authContext, objectMetadataItem } = options; - - assertMutationNotOnRemoteObject(objectMetadataItem); - assertIsValidUuid(args.id); - - const hookedArgs = - await this.workspaceQueryHookService.executePreQueryHooks( - authContext, - objectMetadataItem.nameSingular, - 'destroyOne', - args, - ); - - const computedArgs = (await this.queryRunnerArgsFactory.create( - hookedArgs, - options, - ResolverArgsType.DestroyOne, - )) as DestroyOneResolverArgs; - - const result = (await graphqlQueryDestroyOneResolverService.destroyOne( - computedArgs, - options, - )) as ObjectRecord; - - await this.workspaceQueryHookService.executePostQueryHooks( - authContext, - objectMetadataItem.nameSingular, - 'destroyOne', - [result], - ); - - await this.triggerWebhooks( - [result], - CallWebhookJobsJobOperation.destroy, - options, - ); - - this.emitDestroyEvents([result], authContext, objectMetadataItem); - - return result; - } - - private emitDestroyEvents( - records: BaseRecord[], - authContext: AuthContext, - objectMetadataItem: ObjectMetadataInterface, - ) { - this.workspaceEventEmitter.emit( - `${objectMetadataItem.nameSingular}.destroyed`, - records.map((record) => { - return { - userId: authContext.user?.id, - recordId: record.id, - objectMetadata: objectMetadataItem, - properties: { - before: this.removeNestedProperties(record), - }, - } satisfies ObjectRecordDeleteEvent; - }), - authContext.workspace.id, - ); - } - - private removeNestedProperties( - record: Record, - ) { - if (!record) { - return; - } - - const sanitizedRecord = {}; - - for (const [key, value] of Object.entries(record)) { - if (value && typeof value === 'object' && value['edges']) { - continue; - } - - if (key === '__typename') { - continue; - } - - sanitizedRecord[key] = value; - } - - return sanitizedRecord; - } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts similarity index 99% rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts rename to packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts index b9a81ef24..5ccff3af1 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts @@ -20,7 +20,7 @@ import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspac import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; import { isPlainObject } from 'src/utils/is-plain-object'; -export class ObjectRecordsToGraphqlConnectionMapper { +export class ObjectRecordsToGraphqlConnectionHelper { private objectMetadataMap: ObjectMetadataMap; constructor(objectMetadataMap: ObjectMetadataMap) { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts index f19c7cf06..dd3e5abd4 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts @@ -4,6 +4,7 @@ import { FindOptionsRelations, In, ObjectLiteral, + Repository, } from 'typeorm'; import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; @@ -16,14 +17,70 @@ import { ObjectMetadataMap, ObjectMetadataMapItem, } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { deduceRelationDirection } from 'src/engine/utils/deduce-relation-direction.util'; export class ProcessNestedRelationsHelper { - private readonly twentyORMGlobalManager: TwentyORMGlobalManager; + constructor() {} - constructor(twentyORMGlobalManager: TwentyORMGlobalManager) { - this.twentyORMGlobalManager = twentyORMGlobalManager; + public async processNestedRelations( + objectMetadataMap: ObjectMetadataMap, + parentObjectMetadataItem: ObjectMetadataMapItem, + parentObjectRecords: ObjectRecord[], + relations: Record>, + limit: number, + authContext: any, + dataSource: DataSource, + ): Promise { + const processRelationTasks = Object.entries(relations).map( + ([relationName, nestedRelations]) => + this.processRelation( + objectMetadataMap, + parentObjectMetadataItem, + parentObjectRecords, + relationName, + nestedRelations, + limit, + authContext, + dataSource, + ), + ); + + await Promise.all(processRelationTasks); + } + + private async processRelation( + objectMetadataMap: ObjectMetadataMap, + parentObjectMetadataItem: ObjectMetadataMapItem, + parentObjectRecords: ObjectRecord[], + relationName: string, + nestedRelations: any, + limit: number, + authContext: any, + dataSource: DataSource, + ): Promise { + const relationFieldMetadata = parentObjectMetadataItem.fields[relationName]; + const relationMetadata = getRelationMetadata(relationFieldMetadata); + const relationDirection = deduceRelationDirection( + relationFieldMetadata, + relationMetadata, + ); + + const processor = + relationDirection === 'to' + ? this.processToRelation + : this.processFromRelation; + + await processor.call( + this, + objectMetadataMap, + parentObjectMetadataItem, + parentObjectRecords, + relationName, + nestedRelations, + limit, + authContext, + dataSource, + ); } private async processFromRelation( @@ -35,49 +92,36 @@ export class ProcessNestedRelationsHelper { limit: number, authContext: any, dataSource: DataSource, - ) { - const relationFieldMetadata = parentObjectMetadataItem.fields[relationName]; - const relationMetadata = getRelationMetadata(relationFieldMetadata); - - const inverseRelationName = - objectMetadataMap[relationMetadata.toObjectMetadataId]?.fields[ - relationMetadata.toFieldMetadataId - ]?.name; - - const referenceObjectMetadata = getRelationObjectMetadata( - relationFieldMetadata, - objectMetadataMap, - ); - - const referenceObjectMetadataName = referenceObjectMetadata.nameSingular; - - const relationRepository = await dataSource.getRepository( - referenceObjectMetadataName, - ); - - const relationIds = parentObjectRecords.map((item) => item.id); - - const uniqueRelationIds = [...new Set(relationIds)]; - - const relationFindOptions: FindManyOptions = { - where: { - [`${inverseRelationName}Id`]: In(uniqueRelationIds), - }, - take: limit * parentObjectRecords.length, - }; - - const relationResults = await relationRepository.find(relationFindOptions); - - parentObjectRecords.forEach((item) => { - (item as any)[relationName] = relationResults.filter( - (rel) => rel[`${inverseRelationName}Id`] === item.id, + ): Promise { + const { inverseRelationName, referenceObjectMetadata } = + this.getRelationMetadata( + objectMetadataMap, + parentObjectMetadataItem, + relationName, ); - }); + const relationRepository = dataSource.getRepository( + referenceObjectMetadata.nameSingular, + ); + + const relationIds = this.getUniqueIds(parentObjectRecords, 'id'); + const relationResults = await this.findRelations( + relationRepository, + inverseRelationName, + relationIds, + limit * parentObjectRecords.length, + ); + + this.assignRelationResults( + parentObjectRecords, + relationResults, + relationName, + `${inverseRelationName}Id`, + ); if (Object.keys(nestedRelations).length > 0) { await this.processNestedRelations( objectMetadataMap, - objectMetadataMap[referenceObjectMetadataName], + objectMetadataMap[referenceObjectMetadata.nameSingular], relationResults as ObjectRecord[], nestedRelations as Record>, limit, @@ -96,48 +140,37 @@ export class ProcessNestedRelationsHelper { limit: number, authContext: any, dataSource: DataSource, - ) { - const relationFieldMetadata = parentObjectMetadataItem.fields[relationName]; - - const referenceObjectMetadata = getRelationObjectMetadata( - relationFieldMetadata, + ): Promise { + const { referenceObjectMetadata } = this.getRelationMetadata( objectMetadataMap, + parentObjectMetadataItem, + relationName, ); - - const referenceObjectMetadataName = referenceObjectMetadata.nameSingular; - const relationRepository = dataSource.getRepository( - referenceObjectMetadataName, + referenceObjectMetadata.nameSingular, ); - const relationIds = parentObjectRecords.map( - (item) => item[`${relationName}Id`], + const relationIds = this.getUniqueIds( + parentObjectRecords, + `${relationName}Id`, + ); + const relationResults = await this.findRelations( + relationRepository, + 'id', + relationIds, + limit, ); - const uniqueRelationIds = [...new Set(relationIds)]; - - const relationFindOptions: FindManyOptions = { - where: { - id: In(uniqueRelationIds), - }, - take: limit, - }; - - const relationResults = await relationRepository.find(relationFindOptions); - - parentObjectRecords.forEach((item) => { - if (relationResults.length === 0) { - (item as any)[`${relationName}Id`] = null; - } - (item as any)[relationName] = relationResults.filter( - (rel) => rel.id === item[`${relationName}Id`], - )[0]; - }); + this.assignToRelationResults( + parentObjectRecords, + relationResults, + relationName, + ); if (Object.keys(nestedRelations).length > 0) { await this.processNestedRelations( objectMetadataMap, - objectMetadataMap[referenceObjectMetadataName], + objectMetadataMap[referenceObjectMetadata.nameSingular], relationResults as ObjectRecord[], nestedRelations as Record>, limit, @@ -147,48 +180,71 @@ export class ProcessNestedRelationsHelper { } } - public async processNestedRelations( + private getRelationMetadata( objectMetadataMap: ObjectMetadataMap, parentObjectMetadataItem: ObjectMetadataMapItem, - parentObjectRecords: ObjectRecord[], - relations: Record>, - limit: number, - authContext: any, - dataSource: DataSource, + relationName: string, ) { - for (const [relationName, nestedRelations] of Object.entries(relations)) { - const relationFieldMetadata = - parentObjectMetadataItem.fields[relationName]; - const relationMetadata = getRelationMetadata(relationFieldMetadata); + const relationFieldMetadata = parentObjectMetadataItem.fields[relationName]; + const relationMetadata = getRelationMetadata(relationFieldMetadata); + const referenceObjectMetadata = getRelationObjectMetadata( + relationFieldMetadata, + objectMetadataMap, + ); + const inverseRelationName = + objectMetadataMap[relationMetadata.toObjectMetadataId]?.fields[ + relationMetadata.toFieldMetadataId + ]?.name; - const relationDirection = deduceRelationDirection( - relationFieldMetadata, - relationMetadata, - ); + return { inverseRelationName, referenceObjectMetadata }; + } - if (relationDirection === 'to') { - await this.processToRelation( - objectMetadataMap, - parentObjectMetadataItem, - parentObjectRecords, - relationName, - nestedRelations, - limit, - authContext, - dataSource, - ); - } else { - await this.processFromRelation( - objectMetadataMap, - parentObjectMetadataItem, - parentObjectRecords, - relationName, - nestedRelations, - limit, - authContext, - dataSource, - ); - } + private getUniqueIds(records: IRecord[], idField: string): any[] { + return [...new Set(records.map((item) => item[idField]))]; + } + + private async findRelations( + repository: Repository, + field: string, + ids: any[], + limit: number, + ): Promise { + if (ids.length === 0) { + return []; } + const findOptions: FindManyOptions = { + where: { [field]: In(ids) }, + take: limit, + }; + + return repository.find(findOptions); + } + + private assignRelationResults( + parentRecords: IRecord[], + relationResults: any[], + relationName: string, + joinField: string, + ): void { + parentRecords.forEach((item) => { + (item as any)[relationName] = relationResults.filter( + (rel) => rel[joinField] === item.id, + ); + }); + } + + private assignToRelationResults( + parentRecords: IRecord[], + relationResults: any[], + relationName: string, + ): void { + parentRecords.forEach((item) => { + if (relationResults.length === 0) { + (item as any)[`${relationName}Id`] = null; + } + (item as any)[relationName] = + relationResults.find((rel) => rel.id === item[`${relationName}Id`]) ?? + null; + }); } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts new file mode 100644 index 000000000..f88691647 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts @@ -0,0 +1,12 @@ +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; + +export interface ResolverService { + resolve: ( + args: ResolverArgs, + options: WorkspaceQueryRunnerOptions, + ) => Promise; + validate: ( + args: ResolverArgs, + options: WorkspaceQueryRunnerOptions, + ) => Promise; +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts index f66497a68..aa8a81ce8 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts @@ -1,51 +1,53 @@ +import { Injectable } from '@nestjs/common'; + import graphqlFields from 'graphql-fields'; import { In, InsertResult } from 'typeorm'; +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper'; -import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util'; -import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; +import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; +import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; -export class GraphqlQueryCreateManyResolverService { - private twentyORMGlobalManager: TwentyORMGlobalManager; +@Injectable() +export class GraphqlQueryCreateManyResolverService + implements ResolverService +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} - constructor(twentyORMGlobalManager: TwentyORMGlobalManager) { - this.twentyORMGlobalManager = twentyORMGlobalManager; - } - - async createMany( + async resolve( args: CreateManyResolverArgs>, options: WorkspaceQueryRunnerOptions, - ): Promise { - const { authContext, objectMetadataItem, objectMetadataCollection, info } = + ): Promise { + const { authContext, info, objectMetadataMap, objectMetadataMapItem } = options; - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace( authContext.workspace.id, - objectMetadataItem.nameSingular, ); + const repository = dataSource.getRepository( + objectMetadataMapItem.nameSingular, + ); - const objectMetadataMap = generateObjectMetadataMap( - objectMetadataCollection, - ); - const objectMetadata = getObjectMetadataOrThrow( - objectMetadataMap, - objectMetadataItem.nameSingular, - ); const graphqlQueryParser = new GraphqlQueryParser( - objectMetadata.fields, + objectMetadataMapItem.fields, objectMetadataMap, ); const selectedFields = graphqlFields(info); - const { select, relations } = graphqlQueryParser.parseSelectedFields( - objectMetadataItem, + const { relations } = graphqlQueryParser.parseSelectedFields( + objectMetadataMapItem, selectedFields, ); @@ -56,24 +58,59 @@ export class GraphqlQueryCreateManyResolverService { skipUpdateIfNoValuesChanged: true, }); - const upsertedRecords = await repository.find({ - where: { + const queryBuilder = repository.createQueryBuilder( + objectMetadataMapItem.nameSingular, + ); + + const nonFormattedUpsertedRecords = (await queryBuilder + .where({ id: In(objectRecords.generatedMaps.map((record) => record.id)), - }, - select, - relations, - }); + }) + .take(QUERY_MAX_RECORDS) + .getMany()) as ObjectRecord[]; + + const upsertedRecords = formatResult( + nonFormattedUpsertedRecords, + objectMetadataMapItem, + objectMetadataMap, + ); + + const processNestedRelationsHelper = new ProcessNestedRelationsHelper(); + + if (relations) { + await processNestedRelationsHelper.processNestedRelations( + objectMetadataMap, + objectMetadataMapItem, + upsertedRecords, + relations, + QUERY_MAX_RECORDS, + authContext, + dataSource, + ); + } const typeORMObjectRecordsParser = - new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap); + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap); return upsertedRecords.map((record: ObjectRecord) => typeORMObjectRecordsParser.processRecord( record, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, 1, 1, ), ); } + + async validate( + args: CreateManyResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + assertMutationNotOnRemoteObject(options.objectMetadataItem); + args.data.forEach((record) => { + if (record?.id) { + assertIsValidUuid(record.id); + } + }); + } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts index 53dad3edd..3540dcbf9 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts @@ -1,33 +1,68 @@ +import { Injectable } from '@nestjs/common'; + +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { DestroyOneResolverArgs } 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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; -export class GraphqlQueryDestroyOneResolverService { - private twentyORMGlobalManager: TwentyORMGlobalManager; +@Injectable() +export class GraphqlQueryDestroyOneResolverService + implements ResolverService +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} - constructor(twentyORMGlobalManager: TwentyORMGlobalManager) { - this.twentyORMGlobalManager = twentyORMGlobalManager; - } - - async destroyOne( + async resolve( args: DestroyOneResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise { - const { authContext, objectMetadataItem } = options; + const { authContext, objectMetadataMapItem, objectMetadataMap } = options; const repository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( authContext.workspace.id, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, ); - const record = await repository.findOne({ + const nonFormattedRecordBeforeDeletion = await repository.findOne({ where: { id: args.id }, + withDeleted: true, }); + if (!nonFormattedRecordBeforeDeletion) { + throw new GraphqlQueryRunnerException( + 'Record not found', + GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND, + ); + } + + const recordBeforeDeletion = formatResult( + [nonFormattedRecordBeforeDeletion], + objectMetadataMapItem, + objectMetadataMap, + )[0]; + await repository.delete(args.id); - return record as ObjectRecord; + return recordBeforeDeletion as ObjectRecord; + } + + async validate( + args: DestroyOneResolverArgs, + _options: WorkspaceQueryRunnerOptions, + ): Promise { + if (!args.id) { + throw new GraphqlQueryRunnerException( + 'Missing id', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts new file mode 100644 index 000000000..005619330 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts @@ -0,0 +1,214 @@ +import { Injectable } from '@nestjs/common'; + +import isEmpty from 'lodash.isempty'; +import { In } from 'typeorm'; + +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; +import { + Record as IRecord, + OrderByDirection, + RecordFilter, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import { FindDuplicatesResolverArgs } 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 { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { settings } from 'src/engine/constants/settings'; +import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants'; +import { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; + +@Injectable() +export class GraphqlQueryFindDuplicatesResolverService + implements + ResolverService[]> +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + async resolve( + args: FindDuplicatesResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise[]> { + const { authContext, objectMetadataMapItem, objectMetadataMap } = options; + + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace( + authContext.workspace.id, + ); + const repository = dataSource.getRepository( + objectMetadataMapItem.nameSingular, + ); + const existingRecordsQueryBuilder = repository.createQueryBuilder( + objectMetadataMapItem.nameSingular, + ); + const duplicateRecordsQueryBuilder = repository.createQueryBuilder( + objectMetadataMapItem.nameSingular, + ); + + const graphqlQueryParser = new GraphqlQueryParser( + objectMetadataMap[objectMetadataMapItem.nameSingular].fields, + objectMetadataMap, + ); + + const typeORMObjectRecordsParser = + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap); + + let objectRecords: Partial[] = []; + + if (args.ids) { + const nonFormattedObjectRecords = (await existingRecordsQueryBuilder + .where({ id: In(args.ids) }) + .getMany()) as ObjectRecord[]; + + objectRecords = formatResult( + nonFormattedObjectRecords, + objectMetadataMapItem, + objectMetadataMap, + ); + } else if (args.data && !isEmpty(args.data)) { + objectRecords = formatData(args.data, objectMetadataMapItem); + } + + const duplicateConnections: IConnection[] = await Promise.all( + objectRecords.map(async (record) => { + const duplicateConditions = this.buildDuplicateConditions( + objectMetadataMapItem, + [record], + record.id, + ); + + if (isEmpty(duplicateConditions)) { + return typeORMObjectRecordsParser.createConnection( + [], + objectMetadataMapItem.nameSingular, + 0, + 0, + [{ id: OrderByDirection.AscNullsFirst }], + false, + false, + ); + } + + const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + duplicateRecordsQueryBuilder, + objectMetadataMapItem.nameSingular, + duplicateConditions, + ); + + const nonFormattedDuplicates = + (await withFilterQueryBuilder.getMany()) as ObjectRecord[]; + + const duplicates = formatResult( + nonFormattedDuplicates, + objectMetadataMapItem, + objectMetadataMap, + ); + + return typeORMObjectRecordsParser.createConnection( + duplicates, + objectMetadataMapItem.nameSingular, + duplicates.length, + duplicates.length, + [{ id: OrderByDirection.AscNullsFirst }], + false, + false, + ); + }), + ); + + return duplicateConnections; + } + + private buildDuplicateConditions( + objectMetadataMapItem: ObjectMetadataMapItem, + records?: Partial[] | undefined, + filteringByExistingRecordId?: string, + ): Partial { + if (!records || records.length === 0) { + return {}; + } + + const criteriaCollection = this.getApplicableDuplicateCriteriaCollection( + objectMetadataMapItem, + ); + + const conditions = records.flatMap((record) => { + const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) => + criteria.columnNames.every((columnName) => { + const value = record[columnName] as string | undefined; + + return ( + value && value.length >= settings.minLengthOfStringForDuplicateCheck + ); + }), + ); + + return criteriaWithMatchingArgs.map((criteria) => { + const condition = {}; + + criteria.columnNames.forEach((columnName) => { + condition[columnName] = { eq: record[columnName] }; + }); + + return condition; + }); + }); + + const filter: Partial = {}; + + if (conditions && !isEmpty(conditions)) { + filter.or = conditions; + + if (filteringByExistingRecordId) { + filter.id = { neq: filteringByExistingRecordId }; + } + } + + return filter; + } + + private getApplicableDuplicateCriteriaCollection( + objectMetadataMapItem: ObjectMetadataMapItem, + ) { + return DUPLICATE_CRITERIA_COLLECTION.filter( + (duplicateCriteria) => + duplicateCriteria.objectName === objectMetadataMapItem.nameSingular, + ); + } + + async validate( + args: FindDuplicatesResolverArgs, + _options: WorkspaceQueryRunnerOptions, + ): Promise { + if (!args.data && !args.ids) { + throw new GraphqlQueryRunnerException( + 'You have to provide either "data" or "ids" argument', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + + if (args.data && args.ids) { + throw new GraphqlQueryRunnerException( + 'You cannot provide both "data" and "ids" arguments', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + + if (!args.ids && isEmpty(args.data)) { + throw new GraphqlQueryRunnerException( + 'The "data" condition can not be empty when "ids" input not provided', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts index 5caa30e4e..85fdd3948 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts @@ -1,6 +1,9 @@ +import { Injectable } from '@nestjs/common'; + import { isDefined } from 'class-validator'; import graphqlFields from 'graphql-fields'; +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; import { Record as IRecord, OrderByDirection, @@ -17,26 +20,25 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; -import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper'; import { computeCursorArgFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter'; -import { decodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; -import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util'; import { - ObjectMetadataMapItem, - generateObjectMetadataMap, -} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; + getCursor, + getPaginationInfo, +} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; -export class GraphqlQueryFindManyResolverService { - private twentyORMGlobalManager: TwentyORMGlobalManager; +@Injectable() +export class GraphqlQueryFindManyResolverService + implements ResolverService> +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} - constructor(twentyORMGlobalManager: TwentyORMGlobalManager) { - this.twentyORMGlobalManager = twentyORMGlobalManager; - } - - async findMany< + async resolve< ObjectRecord extends IRecord = IRecord, Filter extends RecordFilter = RecordFilter, OrderBy extends RecordOrderBy = RecordOrderBy, @@ -44,51 +46,41 @@ export class GraphqlQueryFindManyResolverService { args: FindManyResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise> { - const { authContext, objectMetadataItem, info, objectMetadataCollection } = + const { authContext, objectMetadataMapItem, info, objectMetadataMap } = options; - this.validateArgsOrThrow(args); - const dataSource = await this.twentyORMGlobalManager.getDataSourceForWorkspace( authContext.workspace.id, ); const repository = dataSource.getRepository( - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, ); const queryBuilder = repository.createQueryBuilder( - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, ); const countQueryBuilder = repository.createQueryBuilder( - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, ); - const objectMetadataMap = generateObjectMetadataMap( - objectMetadataCollection, - ); - - const objectMetadata = getObjectMetadataOrThrow( - objectMetadataMap, - objectMetadataItem.nameSingular, - ); const graphqlQueryParser = new GraphqlQueryParser( - objectMetadata.fields, + objectMetadataMapItem.fields, objectMetadataMap, ); const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder( countQueryBuilder, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, args.filter ?? ({} as Filter), ); const selectedFields = graphqlFields(info); const { relations } = graphqlQueryParser.parseSelectedFields( - objectMetadataItem, + objectMetadataMapItem, selectedFields, ); const isForwardPagination = !isDefined(args.before); @@ -105,7 +97,7 @@ export class GraphqlQueryFindManyResolverService { ? await withDeletedCountQueryBuilder.getCount() : 0; - const cursor = this.getCursor(args); + const cursor = getCursor(args); let appliedFilters = args.filter ?? ({} as Filter); @@ -118,7 +110,7 @@ export class GraphqlQueryFindManyResolverService { const cursorArgFilter = computeCursorArgFilter( cursor, orderByWithIdCondition, - objectMetadata.fields, + objectMetadataMapItem.fields, isForwardPagination, ); @@ -131,14 +123,14 @@ export class GraphqlQueryFindManyResolverService { const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( queryBuilder, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, appliedFilters, ); const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder( withFilterQueryBuilder, orderByWithIdCondition, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, isForwardPagination, ); @@ -153,11 +145,11 @@ export class GraphqlQueryFindManyResolverService { const objectRecords = formatResult( nonFormattedObjectRecords, - objectMetadata, + objectMetadataMapItem, objectMetadataMap, ); - const { hasNextPage, hasPreviousPage } = this.getPaginationInfo( + const { hasNextPage, hasPreviousPage } = getPaginationInfo( objectRecords, limit, isForwardPagination, @@ -167,14 +159,12 @@ export class GraphqlQueryFindManyResolverService { objectRecords.pop(); } - const processNestedRelationsHelper = new ProcessNestedRelationsHelper( - this.twentyORMGlobalManager, - ); + const processNestedRelationsHelper = new ProcessNestedRelationsHelper(); if (relations) { await processNestedRelationsHelper.processNestedRelations( objectMetadataMap, - objectMetadata, + objectMetadataMapItem, objectRecords, relations, limit, @@ -184,20 +174,25 @@ export class GraphqlQueryFindManyResolverService { } const typeORMObjectRecordsParser = - new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap); + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap); - return typeORMObjectRecordsParser.createConnection( + const result = typeORMObjectRecordsParser.createConnection( objectRecords, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, limit, totalCount, orderByWithIdCondition, hasNextPage, hasPreviousPage, ); + + return result; } - private validateArgsOrThrow(args: FindManyResolverArgs) { + async validate( + args: FindManyResolverArgs, + _options: WorkspaceQueryRunnerOptions, + ): Promise { if (args.first && args.last) { throw new GraphqlQueryRunnerException( 'Cannot provide both first and last', @@ -235,49 +230,4 @@ export class GraphqlQueryFindManyResolverService { ); } } - - private getCursor( - args: FindManyResolverArgs, - ): Record | undefined { - if (args.after) return decodeCursor(args.after); - if (args.before) return decodeCursor(args.before); - - return undefined; - } - - private addOrderByColumnsToSelect( - order: Record, - select: Record, - ) { - for (const column of Object.keys(order || {})) { - if (!select[column]) { - select[column] = true; - } - } - } - - private addForeingKeyColumnsToSelect( - relations: Record, - select: Record, - objectMetadata: ObjectMetadataMapItem, - ) { - for (const column of Object.keys(relations || {})) { - if (!select[`${column}Id`] && objectMetadata.fields[`${column}Id`]) { - select[`${column}Id`] = true; - } - } - } - - private getPaginationInfo( - objectRecords: any[], - limit: number, - isForwardPagination: boolean, - ) { - const hasMoreRecords = objectRecords.length > limit; - - return { - hasNextPage: isForwardPagination && hasMoreRecords, - hasPreviousPage: !isForwardPagination && hasMoreRecords, - }; - } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts index b9e86e420..164f30b68 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts @@ -1,5 +1,8 @@ +import { Injectable } from '@nestjs/common'; + import graphqlFields from 'graphql-fields'; +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; import { Record as IRecord, RecordFilter, @@ -13,28 +16,31 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; -import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper'; -import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util'; -import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; +import { + WorkspaceQueryRunnerException, + WorkspaceQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; -export class GraphqlQueryFindOneResolverService { - private twentyORMGlobalManager: TwentyORMGlobalManager; +@Injectable() +export class GraphqlQueryFindOneResolverService + implements ResolverService +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} - constructor(twentyORMGlobalManager: TwentyORMGlobalManager) { - this.twentyORMGlobalManager = twentyORMGlobalManager; - } - - async findOne< + async resolve< ObjectRecord extends IRecord = IRecord, Filter extends RecordFilter = RecordFilter, >( args: FindOneResolverArgs, options: WorkspaceQueryRunnerOptions, - ): Promise { - const { authContext, objectMetadataItem, info, objectMetadataCollection } = + ): Promise { + const { authContext, objectMetadataMapItem, info, objectMetadataMap } = options; const dataSource = @@ -43,37 +49,28 @@ export class GraphqlQueryFindOneResolverService { ); const repository = dataSource.getRepository( - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, ); const queryBuilder = repository.createQueryBuilder( - objectMetadataItem.nameSingular, - ); - - const objectMetadataMap = generateObjectMetadataMap( - objectMetadataCollection, - ); - - const objectMetadata = getObjectMetadataOrThrow( - objectMetadataMap, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, ); const graphqlQueryParser = new GraphqlQueryParser( - objectMetadata.fields, + objectMetadataMapItem.fields, objectMetadataMap, ); const selectedFields = graphqlFields(info); const { relations } = graphqlQueryParser.parseSelectedFields( - objectMetadataItem, + objectMetadataMapItem, selectedFields, ); const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( queryBuilder, - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, args.filter ?? ({} as Filter), ); @@ -86,12 +83,10 @@ export class GraphqlQueryFindOneResolverService { const objectRecord = formatResult( nonFormattedObjectRecord, - objectMetadata, + objectMetadataMapItem, objectMetadataMap, ); - const limit = QUERY_MAX_RECORDS; - if (!objectRecord) { throw new GraphqlQueryRunnerException( 'Record not found', @@ -99,32 +94,42 @@ export class GraphqlQueryFindOneResolverService { ); } - const processNestedRelationsHelper = new ProcessNestedRelationsHelper( - this.twentyORMGlobalManager, - ); + const processNestedRelationsHelper = new ProcessNestedRelationsHelper(); const objectRecords = [objectRecord]; if (relations) { await processNestedRelationsHelper.processNestedRelations( objectMetadataMap, - objectMetadata, + objectMetadataMapItem, objectRecords, relations, - limit, + QUERY_MAX_RECORDS, authContext, dataSource, ); } const typeORMObjectRecordsParser = - new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap); + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap); return typeORMObjectRecordsParser.processRecord( objectRecords[0], - objectMetadataItem.nameSingular, + objectMetadataMapItem.nameSingular, 1, 1, ) as ObjectRecord; } + + async validate( + args: FindOneResolverArgs, + _options: WorkspaceQueryRunnerOptions, + ): Promise { + if (!args.filter || Object.keys(args.filter).length === 0) { + throw new WorkspaceQueryRunnerException( + 'Missing filter argument', + WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts index c8d1892bb..fe9d86f6c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts @@ -1,3 +1,6 @@ +import { Injectable } from '@nestjs/common'; + +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; import { Record as IRecord, OrderByDirection, @@ -11,47 +14,25 @@ import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; -import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; -import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -export class GraphqlQuerySearchResolverService { - private twentyORMGlobalManager: TwentyORMGlobalManager; - private featureFlagService: FeatureFlagService; - +@Injectable() +export class GraphqlQuerySearchResolverService + implements ResolverService> +{ constructor( - twentyORMGlobalManager: TwentyORMGlobalManager, - featureFlagService: FeatureFlagService, - ) { - this.twentyORMGlobalManager = twentyORMGlobalManager; - this.featureFlagService = featureFlagService; - } + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly featureFlagService: FeatureFlagService, + ) {} - async search( + async resolve( args: SearchResolverArgs, options: WorkspaceQueryRunnerOptions, ): Promise> { - const { authContext, objectMetadataItem, objectMetadataCollection } = - options; - - const featureFlagsForWorkspace = - await this.featureFlagService.getWorkspaceFeatureFlags( - authContext.workspace.id, - ); - - const isQueryRunnerTwentyORMEnabled = - featureFlagsForWorkspace.IS_QUERY_RUNNER_TWENTY_ORM_ENABLED; - - const isSearchEnabled = featureFlagsForWorkspace.IS_SEARCH_ENABLED; - - if (!isQueryRunnerTwentyORMEnabled || !isSearchEnabled) { - throw new GraphqlQueryRunnerException( - 'This endpoint is not available yet, please use findMany instead.', - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } + const { authContext, objectMetadataItem, objectMetadataMap } = options; const repository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( @@ -59,21 +40,8 @@ export class GraphqlQuerySearchResolverService { objectMetadataItem.nameSingular, ); - const objectMetadataMap = generateObjectMetadataMap( - objectMetadataCollection, - ); - - const objectMetadata = objectMetadataMap[objectMetadataItem.nameSingular]; - - if (!objectMetadata) { - throw new GraphqlQueryRunnerException( - `Object metadata not found for ${objectMetadataItem.nameSingular}`, - GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND, - ); - } - const typeORMObjectRecordsParser = - new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap); + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap); if (!args.searchInput) { return typeORMObjectRecordsParser.createConnection( @@ -100,7 +68,7 @@ export class GraphqlQuerySearchResolverService { 'DESC', ) .setParameter('searchTerms', searchTerms) - .limit(limit) + .take(limit) .getMany()) as ObjectRecord[]; const objectRecords = await repository.formatResult(resultsWithTsVector); @@ -129,4 +97,26 @@ export class GraphqlQuerySearchResolverService { return formattedWords.join(' | '); } + + async validate( + _args: SearchResolverArgs, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const featureFlagsForWorkspace = + await this.featureFlagService.getWorkspaceFeatureFlags( + options.authContext.workspace.id, + ); + + const isQueryRunnerTwentyORMEnabled = + featureFlagsForWorkspace.IS_QUERY_RUNNER_TWENTY_ORM_ENABLED; + + const isSearchEnabled = featureFlagsForWorkspace.IS_SEARCH_ENABLED; + + if (!isQueryRunnerTwentyORMEnabled || !isSearchEnabled) { + throw new GraphqlQueryRunnerException( + 'This endpoint is not available yet, please use findMany instead.', + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); + } + } } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts new file mode 100644 index 000000000..149674209 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; + +import graphqlFields from 'graphql-fields'; + +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; +import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import { UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; +import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; +import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; + +@Injectable() +export class GraphqlQueryUpdateManyResolverService + implements ResolverService +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + async resolve( + args: UpdateManyResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const { authContext, objectMetadataMapItem, objectMetadataMap, info } = + options; + + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace( + authContext.workspace.id, + ); + + const repository = dataSource.getRepository( + objectMetadataMapItem.nameSingular, + ); + + const graphqlQueryParser = new GraphqlQueryParser( + objectMetadataMapItem.fields, + objectMetadataMap, + ); + + const selectedFields = graphqlFields(info); + + const { relations } = graphqlQueryParser.parseSelectedFields( + objectMetadataMapItem, + selectedFields, + ); + + const queryBuilder = repository.createQueryBuilder( + objectMetadataMapItem.nameSingular, + ); + + const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder( + queryBuilder, + objectMetadataMapItem.nameSingular, + args.filter, + ); + + const data = formatData(args.data, objectMetadataMapItem); + + const result = await withFilterQueryBuilder + .update() + .set(data) + .returning('*') + .execute(); + + const nonFormattedUpdatedObjectRecords = result.raw; + + const updatedRecords = formatResult( + nonFormattedUpdatedObjectRecords, + objectMetadataMapItem, + objectMetadataMap, + ); + + const processNestedRelationsHelper = new ProcessNestedRelationsHelper(); + + if (relations) { + await processNestedRelationsHelper.processNestedRelations( + objectMetadataMap, + objectMetadataMapItem, + updatedRecords, + relations, + QUERY_MAX_RECORDS, + authContext, + dataSource, + ); + } + + const typeORMObjectRecordsParser = + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap); + + return updatedRecords.map((record: ObjectRecord) => + typeORMObjectRecordsParser.processRecord( + record, + objectMetadataMapItem.nameSingular, + 1, + 1, + ), + ); + } + + async validate( + args: UpdateManyResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + assertMutationNotOnRemoteObject(options.objectMetadataMapItem); + args.filter?.id?.in?.forEach((id: string) => assertIsValidUuid(id)); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts new file mode 100644 index 000000000..7be966cc6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts @@ -0,0 +1,123 @@ +import { Injectable } from '@nestjs/common'; + +import graphqlFields from 'graphql-fields'; + +import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface'; +import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; +import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant'; +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; +import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; +import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; + +@Injectable() +export class GraphqlQueryUpdateOneResolverService + implements ResolverService +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + async resolve( + args: UpdateOneResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + const { authContext, objectMetadataMapItem, objectMetadataMap, info } = + options; + + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace( + authContext.workspace.id, + ); + + const repository = dataSource.getRepository( + objectMetadataMapItem.nameSingular, + ); + + const graphqlQueryParser = new GraphqlQueryParser( + objectMetadataMapItem.fields, + objectMetadataMap, + ); + + const selectedFields = graphqlFields(info); + + const { relations } = graphqlQueryParser.parseSelectedFields( + objectMetadataMapItem, + selectedFields, + ); + + const queryBuilder = repository.createQueryBuilder( + objectMetadataMapItem.nameSingular, + ); + + const withFilterQueryBuilder = queryBuilder.where({ id: args.id }); + + const data = formatData(args.data, objectMetadataMapItem); + + const result = await withFilterQueryBuilder + .update() + .set(data) + .returning('*') + .execute(); + + const nonFormattedUpdatedObjectRecords = result.raw; + + const updatedRecords = formatResult( + nonFormattedUpdatedObjectRecords, + objectMetadataMapItem, + objectMetadataMap, + ); + + if (updatedRecords.length === 0) { + throw new GraphqlQueryRunnerException( + 'Record not found', + GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND, + ); + } + + const updatedRecord = updatedRecords[0] as ObjectRecord; + + const processNestedRelationsHelper = new ProcessNestedRelationsHelper(); + + if (relations) { + await processNestedRelationsHelper.processNestedRelations( + objectMetadataMap, + objectMetadataMapItem, + [updatedRecord], + relations, + QUERY_MAX_RECORDS, + authContext, + dataSource, + ); + } + + const typeORMObjectRecordsParser = + new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap); + + return typeORMObjectRecordsParser.processRecord( + updatedRecord, + objectMetadataMapItem.nameSingular, + 1, + 1, + ); + } + + async validate( + args: UpdateOneResolverArgs>, + options: WorkspaceQueryRunnerOptions, + ): Promise { + assertMutationNotOnRemoteObject(options.objectMetadataMapItem); + assertIsValidUuid(args.id); + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts new file mode 100644 index 000000000..8cb2c9cc7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts @@ -0,0 +1,137 @@ +import { Injectable } from '@nestjs/common'; + +import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; + +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; + +@Injectable() +export class ApiEventEmitterService { + constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {} + + public emitCreateEvents( + records: T[], + authContext: AuthContext, + objectMetadataItem: ObjectMetadataInterface, + ): void { + this.workspaceEventEmitter.emit( + `${objectMetadataItem.nameSingular}.created`, + records.map((record) => ({ + userId: authContext.user?.id, + recordId: record.id, + objectMetadata: objectMetadataItem, + properties: { + before: null, + after: this.removeGraphQLAndNestedProperties(record), + }, + })), + authContext.workspace.id, + ); + } + + public emitUpdateEvents( + existingRecords: T[], + records: T[], + updatedFields: string[], + authContext: AuthContext, + objectMetadataItem: ObjectMetadataInterface, + ): void { + const mappedExistingRecords = existingRecords.reduce( + (acc, { id, ...record }) => ({ + ...acc, + [id]: record, + }), + {}, + ); + + this.workspaceEventEmitter.emit( + `${objectMetadataItem.nameSingular}.updated`, + records.map((record) => { + return { + userId: authContext.user?.id, + recordId: record.id, + objectMetadata: objectMetadataItem, + properties: { + before: mappedExistingRecords[record.id] + ? this.removeGraphQLAndNestedProperties( + mappedExistingRecords[record.id], + ) + : undefined, + after: this.removeGraphQLAndNestedProperties(record), + updatedFields, + }, + }; + }), + authContext.workspace.id, + ); + } + + public emitDeletedEvents( + records: T[], + authContext: AuthContext, + objectMetadataItem: ObjectMetadataInterface, + ): void { + this.workspaceEventEmitter.emit( + `${objectMetadataItem.nameSingular}.deleted`, + records.map((record) => { + return { + userId: authContext.user?.id, + recordId: record.id, + objectMetadata: objectMetadataItem, + properties: { + before: this.removeGraphQLAndNestedProperties(record), + after: null, + }, + }; + }), + authContext.workspace.id, + ); + } + + public emitDestroyEvents( + records: T[], + authContext: AuthContext, + objectMetadataItem: ObjectMetadataInterface, + ): void { + this.workspaceEventEmitter.emit( + `${objectMetadataItem.nameSingular}.destroyed`, + records.map((record) => { + return { + userId: authContext.user?.id, + recordId: record.id, + objectMetadata: objectMetadataItem, + properties: { + before: this.removeGraphQLAndNestedProperties(record), + after: null, + }, + }; + }), + authContext.workspace.id, + ); + } + + private removeGraphQLAndNestedProperties( + record: ObjectRecord, + ) { + if (!record) { + return {}; + } + + const sanitizedRecord = {}; + + for (const [key, value] of Object.entries(record)) { + if (value && typeof value === 'object' && value['edges']) { + continue; + } + + if (key === '__typename') { + continue; + } + + sanitizedRecord[key] = value; + } + + return sanitizedRecord; + } +} diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts index bf8eb52d0..bd27522ce 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts @@ -2,6 +2,7 @@ import { Record as IRecord, RecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { GraphqlQueryRunnerException, @@ -44,3 +45,25 @@ export const encodeCursor = ( return Buffer.from(JSON.stringify(cursorData)).toString('base64'); }; + +export const getCursor = ( + args: FindManyResolverArgs, +): Record | undefined => { + if (args.after) return decodeCursor(args.after); + if (args.before) return decodeCursor(args.before); + + return undefined; +}; + +export const getPaginationInfo = ( + objectRecords: any[], + limit: number, + isForwardPagination: boolean, +) => { + const hasMoreRecords = objectRecords.length > limit; + + return { + hasNextPage: isForwardPagination && hasMoreRecords, + hasPreviousPage: !isForwardPagination && hasMoreRecords, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts index 45883f99d..d960c3d45 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts @@ -4,6 +4,10 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { + ObjectMetadataMap, + ObjectMetadataMapItem, +} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; export interface WorkspaceQueryRunnerOptions { authContext: AuthContext; @@ -11,4 +15,6 @@ export interface WorkspaceQueryRunnerOptions { objectMetadataItem: ObjectMetadataInterface; fieldMetadataCollection: FieldMetadataInterface[]; objectMetadataCollection: ObjectMetadataInterface[]; + objectMetadataMap: ObjectMetadataMap; + objectMetadataMapItem: ObjectMetadataMapItem; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts index b75c939d1..034c73bda 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts @@ -9,6 +9,7 @@ import { FindManyResolverArgs, FindOneResolverArgs, RestoreManyResolverArgs, + SearchResolverArgs, UpdateManyResolverArgs, UpdateOneResolverArgs, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; @@ -42,4 +43,6 @@ export type WorkspacePreQueryHookPayload = T extends 'createMany' ? DestroyManyResolverArgs : T extends 'destroyOne' ? DestroyOneResolverArgs - : never; + : T extends 'search' + ? SearchResolverArgs + : never; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts index 872ab7906..06a8d5507 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { CreateManyResolverArgs, @@ -32,12 +33,14 @@ export class CreateManyResolverFactory return async (_source, args, _context, info) => { try { - const options = { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, }; const isQueryRunnerTwentyORMEnabled = diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts index edf9206a1..5922d0555 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { CreateOneResolverArgs, @@ -32,12 +33,14 @@ export class CreateOneResolverFactory return async (_source, args, _context, info) => { try { - const options = { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, }; const isQueryRunnerTwentyORMEnabled = @@ -50,13 +53,7 @@ export class CreateOneResolverFactory return await this.graphqlQueryRunnerService.createOne(args, options); } - return await this.workspaceQueryRunnerService.createOne(args, { - authContext: internalContext.authContext, - objectMetadataItem: internalContext.objectMetadataItem, - info, - fieldMetadataCollection: internalContext.fieldMetadataCollection, - objectMetadataCollection: internalContext.objectMetadataCollection, - }); + return await this.workspaceQueryRunnerService.createOne(args, options); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts index a8d36f3e4..4a32ad5ea 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { DeleteManyResolverArgs, @@ -7,8 +8,11 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class DeleteManyResolverFactory @@ -18,6 +22,8 @@ export class DeleteManyResolverFactory constructor( private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, + private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService, ) {} create( @@ -27,13 +33,27 @@ export class DeleteManyResolverFactory return async (_source, args, context, info) => { try { - return await this.workspaceQueryRunnerService.deleteMany(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + const isQueryRunnerTwentyORMEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsQueryRunnerTwentyORMEnabled, + internalContext.authContext.workspace.id, + ); + + if (isQueryRunnerTwentyORMEnabled) { + return await this.graphqlQueryRunnerService.deleteMany(args, options); + } + + return await this.workspaceQueryRunnerService.deleteMany(args, options); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts index 93f249cdd..d58ebe02f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { DeleteOneResolverArgs, @@ -7,8 +8,11 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class DeleteOneResolverFactory @@ -18,6 +22,8 @@ export class DeleteOneResolverFactory constructor( private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, + private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService, ) {} create( @@ -27,13 +33,27 @@ export class DeleteOneResolverFactory return async (_source, args, context, info) => { try { - return await this.workspaceQueryRunnerService.deleteOne(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + const isQueryRunnerTwentyORMEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsQueryRunnerTwentyORMEnabled, + internalContext.authContext.workspace.id, + ); + + if (isQueryRunnerTwentyORMEnabled) { + return await this.graphqlQueryRunnerService.deleteOne(args, options); + } + + return await this.workspaceQueryRunnerService.deleteOne(args, options); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts index 4a064a406..2e6cf835e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { DestroyManyResolverArgs, @@ -27,13 +28,20 @@ export class DestroyManyResolverFactory return async (_source, args, context, info) => { try { - return await this.workspaceQueryRunnerService.destroyMany(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + return await this.workspaceQueryRunnerService.destroyMany( + args, + options, + ); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts index 4c204d6e8..bb1e2aaaa 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { DestroyOneResolverArgs, @@ -27,13 +28,17 @@ export class DestroyOneResolverFactory return async (_source, args, context, info) => { try { - return await this.graphQLQueryRunnerService.destroyOne(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + return await this.graphQLQueryRunnerService.destroyOne(args, options); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts index 0a1494efb..f8b57ad22 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { FindDuplicatesResolverArgs, @@ -7,8 +8,11 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class FindDuplicatesResolverFactory @@ -18,6 +22,8 @@ export class FindDuplicatesResolverFactory constructor( private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, + private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService, ) {} create( @@ -27,13 +33,33 @@ export class FindDuplicatesResolverFactory return async (_source, args, context, info) => { try { - return await this.workspaceQueryRunnerService.findDuplicates(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + const isQueryRunnerTwentyORMEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsQueryRunnerTwentyORMEnabled, + internalContext.authContext.workspace.id, + ); + + if (isQueryRunnerTwentyORMEnabled) { + return await this.graphqlQueryRunnerService.findDuplicates( + args, + options, + ); + } + + return await this.workspaceQueryRunnerService.findDuplicates( + args, + options, + ); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts index 2dd452a29..c695079e2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { FindManyResolverArgs, @@ -27,12 +28,14 @@ export class FindManyResolverFactory return async (_source, args, _context, info) => { try { - const options = { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, }; return await this.graphqlQueryRunnerService.findMany(args, options); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts index 3dbbc2330..00845e841 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { FindOneResolverArgs, @@ -27,12 +28,14 @@ export class FindOneResolverFactory return async (_source, args, _context, info) => { try { - const options = { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, }; return await this.graphqlQueryRunnerService.findOne(args, options); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts index ceba95306..d92210040 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { Resolver, @@ -7,8 +8,11 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class RestoreManyResolverFactory @@ -18,6 +22,8 @@ export class RestoreManyResolverFactory constructor( private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, + private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService, ) {} create( @@ -27,13 +33,33 @@ export class RestoreManyResolverFactory return async (_source, args, context, info) => { try { - return await this.workspaceQueryRunnerService.restoreMany(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + const isQueryRunnerTwentyORMEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsQueryRunnerTwentyORMEnabled, + internalContext.authContext.workspace.id, + ); + + if (isQueryRunnerTwentyORMEnabled) { + return await this.graphqlQueryRunnerService.restoreMany( + args, + options, + ); + } + + return await this.workspaceQueryRunnerService.restoreMany( + args, + options, + ); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts index 5b32d5279..9d559b656 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { Resolver, @@ -25,13 +26,17 @@ export class SearchResolverFactory return async (_source, args, _context, info) => { try { - return await this.graphqlQueryRunnerService.search(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + return await this.graphqlQueryRunnerService.search(args, options); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts index c2328d12b..11027e4cc 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { Resolver, @@ -7,8 +8,11 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class UpdateManyResolverFactory @@ -18,6 +22,8 @@ export class UpdateManyResolverFactory constructor( private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, + private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService, ) {} create( @@ -27,13 +33,27 @@ export class UpdateManyResolverFactory return async (_source, args, context, info) => { try { - return await this.workspaceQueryRunnerService.updateMany(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + const isQueryRunnerTwentyORMEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsQueryRunnerTwentyORMEnabled, + internalContext.authContext.workspace.id, + ); + + if (isQueryRunnerTwentyORMEnabled) { + return await this.graphqlQueryRunnerService.updateMany(args, options); + } + + return await this.workspaceQueryRunnerService.updateMany(args, options); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts index c7a7dc6ba..13a2e4f71 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { Resolver, @@ -7,8 +8,11 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class UpdateOneResolverFactory @@ -18,6 +22,8 @@ export class UpdateOneResolverFactory constructor( private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, + private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService, ) {} create( @@ -27,13 +33,27 @@ export class UpdateOneResolverFactory return async (_source, args, context, info) => { try { - return await this.workspaceQueryRunnerService.updateOne(args, { + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, objectMetadataItem: internalContext.objectMetadataItem, info, fieldMetadataCollection: internalContext.fieldMetadataCollection, objectMetadataCollection: internalContext.objectMetadataCollection, - }); + objectMetadataMap: internalContext.objectMetadataMap, + objectMetadataMapItem: internalContext.objectMetadataMapItem, + }; + + const isQueryRunnerTwentyORMEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsQueryRunnerTwentyORMEnabled, + internalContext.authContext.workspace.id, + ); + + if (isQueryRunnerTwentyORMEnabled) { + return await this.graphqlQueryRunnerService.updateOne(args, options); + } + + return await this.workspaceQueryRunnerService.updateOne(args, options); } catch (error) { workspaceQueryRunnerGraphqlApiExceptionHandler(error); } 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 616c73458..a652e3065 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 @@ -11,6 +11,7 @@ import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-res import { SearchResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory'; import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { ObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; import { CreateManyResolverFactory } from './factories/create-many-resolver.factory'; @@ -49,6 +50,7 @@ export class WorkspaceResolverFactory { async create( authContext: AuthContext, objectMetadataCollection: ObjectMetadataInterface[], + objectMetadataMap: ObjectMetadataMap, workspaceResolverBuilderMethods: WorkspaceResolverBuilderMethods, ): Promise { const factories = new Map< @@ -94,7 +96,9 @@ export class WorkspaceResolverFactory { authContext, objectMetadataItem: objectMetadata, fieldMetadataCollection: objectMetadata.fields, - objectMetadataCollection: objectMetadataCollection, + objectMetadataCollection, + objectMetadataMap, + objectMetadataMapItem: objectMetadataMap[objectMetadata.nameSingular], }); } @@ -117,7 +121,9 @@ export class WorkspaceResolverFactory { authContext, objectMetadataItem: objectMetadata, fieldMetadataCollection: objectMetadata.fields, - objectMetadataCollection: objectMetadataCollection, + objectMetadataCollection, + objectMetadataMap, + objectMetadataMapItem: objectMetadataMap[objectMetadata.nameSingular], }); } } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts index f5a6aec8b..d0ab66983 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts @@ -2,10 +2,16 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { + ObjectMetadataMap, + ObjectMetadataMapItem, +} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; export interface WorkspaceSchemaBuilderContext { authContext: AuthContext; - objectMetadataItem: ObjectMetadataInterface; fieldMetadataCollection: FieldMetadataInterface[]; objectMetadataCollection: ObjectMetadataInterface[]; + objectMetadataItem: ObjectMetadataInterface; + objectMetadataMap: ObjectMetadataMap; + objectMetadataMapItem: ObjectMetadataMapItem; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 336fa825f..32a44ad4d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -117,6 +117,7 @@ export class WorkspaceSchemaFactory { const autoGeneratedResolvers = await this.workspaceResolverFactory.create( authContext, objectMetadataCollection, + objectMetadataMap, workspaceResolverBuilderMethodNames, ); const scalarsResolvers = diff --git a/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts b/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts index d7ed6f87b..48d021b5e 100644 --- a/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts +++ b/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts @@ -1,15 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { Record as IRecord, Record, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface'; +import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; import { settings } from 'src/engine/constants/settings'; +import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; -import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants'; @Injectable() export class DuplicateService { @@ -94,80 +94,4 @@ export class DuplicateService { duplicateCriteria.objectName === objectMetadataItem.nameSingular, ); } - - /** - * TODO: Remove this code by September 1st, 2024 if it isn't used - * It was build to be used by the upsertMany function, but it was not used. - * It's a re-implementation of the methods to findDuplicates, but done - * at the SQL layer instead of doing it at the GraphQL layer - * - async findDuplicate( - data: Partial, - objectMetadata: ObjectMetadataInterface, - workspaceId: string, - ) { - const dataSourceSchema = - this.workspaceDataSourceService.getSchemaName(workspaceId); - - const { duplicateWhereClause, duplicateWhereParameters } = - this.buildDuplicateConditionForUpsert(objectMetadata, data); - - const results = await this.workspaceDataSourceService.executeRawQuery( - ` - SELECT - * - FROM - ${dataSourceSchema}."${computeObjectTargetTable( - objectMetadata, - )}" p - WHERE - ${duplicateWhereClause} - `, - duplicateWhereParameters, - workspaceId, - ); - - return results.length > 0 ? results[0] : null; - } - - private buildDuplicateConditionForUpsert( - objectMetadata: ObjectMetadataInterface, - data: Partial, - ) { - const criteriaCollection = this.getApplicableDuplicateCriteriaCollection( - objectMetadata, - ).filter( - (duplicateCriteria) => duplicateCriteria.useAsUniqueKeyForUpsert === true, - ); - - const whereClauses: string[] = []; - const whereParameters: any[] = []; - let parameterIndex = 1; - - criteriaCollection.forEach((c) => { - const clauseParts: string[] = []; - - c.columnNames.forEach((column) => { - const dataKey = Object.keys(data).find( - (key) => key.toLowerCase() === column.toLowerCase(), - ); - - if (dataKey) { - clauseParts.push(`p."${column}" = $${parameterIndex}`); - whereParameters.push(data[dataKey]); - parameterIndex++; - } - }); - if (clauseParts.length > 0) { - whereClauses.push(`(${clauseParts.join(' AND ')})`); - } - }); - - const duplicateWhereClause = whereClauses.join(' OR '); - const duplicateWhereParameters = whereParameters; - - return { duplicateWhereClause, duplicateWhereParameters }; - } - * - */ } diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts index 7e44586d0..07471ac9b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts @@ -60,8 +60,8 @@ export class IndexMetadataEntity { @Column({ type: 'enum', enum: IndexType, - nullable: true, default: IndexType.BTREE, + nullable: false, }) indexType?: IndexType; } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts index f6f31f32c..cd3851393 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts @@ -1,9 +1,11 @@ -import { isPlainObject } from '@nestjs/common/utils/shared.utils'; +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; -import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { FieldMetadataType } 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 { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util'; -import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection'; +import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; +import { capitalize } from 'src/utils/capitalize'; export function formatData( data: T, @@ -17,49 +19,70 @@ export function formatData( return data.map((item) => formatData(item, objectMetadata)) as T; } - const compositeFieldMetadataCollection = - getCompositeFieldMetadataCollection(objectMetadata); - - const compositeFieldMetadataMap = new Map( - compositeFieldMetadataCollection.map((fieldMetadata) => [ - fieldMetadata.name, - fieldMetadata, - ]), - ); - const newData: object = {}; + const newData: Record = {}; for (const [key, value] of Object.entries(data)) { - const fieldMetadata = compositeFieldMetadataMap.get(key); + const fieldMetadata = objectMetadata.fields[key]; if (!fieldMetadata) { - if (isPlainObject(value)) { - newData[key] = formatData(value, objectMetadata); - } else { - newData[key] = value; - } - continue; - } - - const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); - - if (!compositeType) { - continue; - } - - for (const compositeProperty of compositeType.properties) { - const compositeKey = computeCompositeColumnName( - fieldMetadata.name, - compositeProperty, + throw new Error( + `Field metadata for field "${key}" is missing in object metadata`, ); - const value = data?.[key]?.[compositeProperty.name]; + } - if (value === undefined || value === null) { - continue; - } + if (isCompositeFieldMetadataType(fieldMetadata.type)) { + const formattedCompositeField = formatCompositeField( + value, + fieldMetadata, + ); - newData[compositeKey] = data[key][compositeProperty.name]; + Object.assign(newData, formattedCompositeField); + } else { + newData[key] = formatFieldMetadataValue(value, fieldMetadata); } } return newData as T; } + +function formatCompositeField( + value: any, + fieldMetadata: FieldMetadataInterface, +): Record { + const compositeType = compositeTypeDefinitions.get( + fieldMetadata.type as CompositeFieldMetadataType, + ); + + if (!compositeType) { + throw new Error( + `Composite type definition not found for type: ${fieldMetadata.type}`, + ); + } + + const formattedCompositeField: Record = {}; + + for (const property of compositeType.properties) { + const subFieldKey = property.name; + const fullFieldName = `${fieldMetadata.name}${capitalize(subFieldKey)}`; + + if (value && value[subFieldKey] !== undefined) { + formattedCompositeField[fullFieldName] = formatFieldMetadataValue( + value[subFieldKey], + property as unknown as FieldMetadataInterface, + ); + } + } + + return formattedCompositeField; +} + +function formatFieldMetadataValue( + value: any, + fieldMetadata: FieldMetadataInterface, +) { + if (fieldMetadata.type === FieldMetadataType.RAW_JSON) { + return JSON.parse(value as string); + } + + return value; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts index 81949e074..2b29ea908 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts @@ -1,6 +1,9 @@ import { isPlainObject } from '@nestjs/common/utils/shared.utils'; +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; + import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { @@ -81,9 +84,15 @@ export function formatResult( if (!compositePropertyArgs && !relationMetadata) { if (isPlainObject(value)) { newData[key] = formatResult(value, objectMetadata, objectMetadataMap); + } else if (objectMetadata.fields[key]) { + newData[key] = formatFieldMetadataValue( + value, + objectMetadata.fields[key], + ); } else { newData[key] = value; } + continue; } @@ -129,3 +138,18 @@ export function formatResult( return newData as T; } + +function formatFieldMetadataValue( + value: any, + fieldMetadata: FieldMetadataInterface, +) { + if ( + typeof value === 'string' && + (fieldMetadata.type === FieldMetadataType.MULTI_SELECT || + fieldMetadata.type === FieldMetadataType.ARRAY) + ) { + return value.replace(/{|}/g, '').split(','); + } + + return value; +}