diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index fd7d10775..dc7e64593 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -55,3 +55,4 @@ SIGN_IN_PREFILLED=true # PASSWORD_RESET_TOKEN_EXPIRES_IN=5m # API_RATE_LIMITING_TTL= # API_RATE_LIMITING_LIMIT= +# MUTATION_MAXIMUM_RECORD_AFFECTED=100 diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index 30e724bef..5e090b245 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -21,3 +21,4 @@ REFRESH_TOKEN_SECRET=secret_refresh_token # MESSAGING_PROVIDER_GMAIL_ENABLED=false # STORAGE_TYPE=local # STORAGE_LOCAL_PATH=.local-storage +# MUTATION_MAXIMUM_RECORD_AFFECTED=100 diff --git a/packages/twenty-server/src/integrations/environment/environment.service.ts b/packages/twenty-server/src/integrations/environment/environment.service.ts index 93a2b7c21..0a372f20e 100644 --- a/packages/twenty-server/src/integrations/environment/environment.service.ts +++ b/packages/twenty-server/src/integrations/environment/environment.service.ts @@ -299,4 +299,10 @@ export class EnvironmentService { getApiRateLimitingLimit(): number { return this.configService.get('API_RATE_LIMITING_LIMIT') ?? 500; } + + getMutationMaximumRecordAffected(): number { + return ( + this.configService.get('MUTATION_MAXIMUM_RECORD_AFFECTED') ?? 100 + ); + } } diff --git a/packages/twenty-server/src/integrations/environment/environment.validation.ts b/packages/twenty-server/src/integrations/environment/environment.validation.ts index ba2c3d59b..de42aaf0f 100644 --- a/packages/twenty-server/src/integrations/environment/environment.validation.ts +++ b/packages/twenty-server/src/integrations/environment/environment.validation.ts @@ -199,6 +199,11 @@ export class EnvironmentVariables { @IsOptional() @IsBoolean() IS_SIGN_UP_DISABLED?: boolean; + + @CastToPositiveNumber() + @IsOptional() + @IsNumber() + MUTATION_MAXIMUM_RECORD_AFFECTED: number; } export const validate = (config: Record) => { diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/delete-many-query.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/delete-many-query.factory.ts index e5b222098..b0082b5b7 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/factories/delete-many-query.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/delete-many-query.factory.ts @@ -8,13 +8,18 @@ import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-tar import { FieldsStringFactory } from './fields-string.factory'; +export interface DeleteManyQueryFactoryOptions + extends WorkspaceQueryBuilderOptions { + atMost?: number; +} + @Injectable() export class DeleteManyQueryFactory { constructor(private readonly fieldsStringFactory: FieldsStringFactory) {} async create( args: DeleteManyResolverArgs, - options: WorkspaceQueryBuilderOptions, + options: DeleteManyQueryFactoryOptions, ) { const fieldsString = await this.fieldsStringFactory.create( options.info, @@ -28,7 +33,7 @@ export class DeleteManyQueryFactory { options.objectMetadataItem, )}Collection(filter: ${stringifyWithoutKeyQuote( args.filter, - )}, atMost: 30) { + )}, atMost: ${options.atMost ?? 1}) { affectedCount records { ${fieldsString} diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts index b5ec97d49..611b33ac8 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/factories.ts @@ -3,7 +3,7 @@ import { ArgsStringFactory } from './args-string.factory'; import { RelationFieldAliasFactory } from './relation-field-alias.factory'; import { CreateManyQueryFactory } from './create-many-query.factory'; import { DeleteOneQueryFactory } from './delete-one-query.factory'; -import { FieldAliasFacotry } from './field-alias.factory'; +import { FieldAliasFactory } from './field-alias.factory'; import { FieldsStringFactory } from './fields-string.factory'; import { FindManyQueryFactory } from './find-many-query.factory'; import { FindOneQueryFactory } from './find-one-query.factory'; @@ -17,7 +17,7 @@ export const workspaceQueryBuilderFactories = [ RelationFieldAliasFactory, CreateManyQueryFactory, DeleteOneQueryFactory, - FieldAliasFacotry, + FieldAliasFactory, FieldsStringFactory, FindManyQueryFactory, FindOneQueryFactory, diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/field-alias.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/field-alias.factory.ts index e00a6f39d..fd2f684e1 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/factories/field-alias.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/field-alias.factory.ts @@ -3,8 +3,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface'; @Injectable() -export class FieldAliasFacotry { - private readonly logger = new Logger(FieldAliasFacotry.name); +export class FieldAliasFactory { + private readonly logger = new Logger(FieldAliasFactory.name); create(fieldKey: string, fieldMetadata: FieldMetadataInterface) { const entries = Object.entries(fieldMetadata.targetColumnMap); diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/fields-string.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/fields-string.factory.ts index e9762d3f5..1358a48dd 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/factories/fields-string.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/fields-string.factory.ts @@ -9,7 +9,7 @@ import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/ import { isRelationFieldMetadataType } from 'src/workspace/utils/is-relation-field-metadata-type.util'; -import { FieldAliasFacotry } from './field-alias.factory'; +import { FieldAliasFactory } from './field-alias.factory'; import { RelationFieldAliasFactory } from './relation-field-alias.factory'; @Injectable() @@ -17,7 +17,7 @@ export class FieldsStringFactory { private readonly logger = new Logger(FieldsStringFactory.name); constructor( - private readonly fieldAliasFactory: FieldAliasFacotry, + private readonly fieldAliasFactory: FieldAliasFactory, private readonly relationFieldAliasFactory: RelationFieldAliasFactory, ) {} diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/factories/update-many-query.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/factories/update-many-query.factory.ts index a534dddc9..37d7ed501 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/factories/update-many-query.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/factories/update-many-query.factory.ts @@ -12,6 +12,11 @@ import { FieldsStringFactory } from 'src/workspace/workspace-query-builder/facto import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory'; import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; +export interface UpdateManyQueryFactoryOptions + extends WorkspaceQueryBuilderOptions { + atMost?: number; +} + @Injectable() export class UpdateManyQueryFactory { constructor( @@ -24,7 +29,7 @@ export class UpdateManyQueryFactory { Filter extends RecordFilter = RecordFilter, >( args: UpdateManyResolverArgs, - options: WorkspaceQueryBuilderOptions, + options: UpdateManyQueryFactoryOptions, ) { const fieldsString = await this.fieldsStringFactory.create( options.info, @@ -47,6 +52,7 @@ export class UpdateManyQueryFactory { update${computeObjectTargetTable(options.objectMetadataItem)}Collection( set: ${stringifyWithoutKeyQuote(argsData)}, filter: ${stringifyWithoutKeyQuote(args.filter)}, + atMost: ${options.atMost ?? 1}, ) { affectedCount records { diff --git a/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts b/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts index 1d76e9970..bdc104b0a 100644 --- a/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-query-builder/workspace-query-builder.factory.ts @@ -21,8 +21,14 @@ import { FindOneQueryFactory } from './factories/find-one-query.factory'; import { CreateManyQueryFactory } from './factories/create-many-query.factory'; import { UpdateOneQueryFactory } from './factories/update-one-query.factory'; import { DeleteOneQueryFactory } from './factories/delete-one-query.factory'; -import { UpdateManyQueryFactory } from './factories/update-many-query.factory'; -import { DeleteManyQueryFactory } from './factories/delete-many-query.factory'; +import { + UpdateManyQueryFactory, + UpdateManyQueryFactoryOptions, +} from './factories/update-many-query.factory'; +import { + DeleteManyQueryFactory, + DeleteManyQueryFactoryOptions, +} from './factories/delete-many-query.factory'; @Injectable() export class WorkspaceQueryBuilderFactory { @@ -81,14 +87,14 @@ export class WorkspaceQueryBuilderFactory { Filter extends RecordFilter = RecordFilter, >( args: UpdateManyResolverArgs, - options: WorkspaceQueryBuilderOptions, + options: UpdateManyQueryFactoryOptions, ): Promise { return this.updateManyQueryFactory.create(args, options); } deleteMany( args: DeleteManyResolverArgs, - options: WorkspaceQueryBuilderOptions, + options: DeleteManyQueryFactoryOptions, ): Promise { return this.deleteManyQueryFactory.create(args, options); } diff --git a/packages/twenty-server/src/workspace/workspace-query-runner/utils/compute-pg-graphql-error.util.ts b/packages/twenty-server/src/workspace/workspace-query-runner/utils/compute-pg-graphql-error.util.ts new file mode 100644 index 000000000..8dfa96ed4 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-query-runner/utils/compute-pg-graphql-error.util.ts @@ -0,0 +1,34 @@ +import { + BadRequestException, + HttpException, + InternalServerErrorException, +} from '@nestjs/common'; + +interface PgGraphQLErrorMapping { + [key: string]: (command: string, objectName: string) => HttpException; +} + +const pgGraphQLErrorMapping: PgGraphQLErrorMapping = { + 'delete impacts too many records': (command, objectName) => + new BadRequestException( + `Cannot ${command} ${objectName} because it impacts too many records.`, + ), +}; + +export const computePgGraphQLError = ( + command: string, + objectName: string, + errors: any[], +) => { + const error = errors[0]; + const errorMessage = error?.message; + const mappedError = pgGraphQLErrorMapping[errorMessage]; + + if (mappedError) { + return mappedError(command, objectName); + } + + return new InternalServerErrorException( + `GraphQL errors on ${command}${objectName}: ${JSON.stringify(error)}`, + ); +}; diff --git a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts index f7467e07f..035395746 100644 --- a/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts +++ b/packages/twenty-server/src/workspace/workspace-query-runner/workspace-query-runner.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - Inject, - Injectable, - InternalServerErrorException, -} from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { IConnection } from 'src/utils/pagination/interfaces/connection.interface'; @@ -39,12 +34,14 @@ import { ObjectRecordDeleteEvent } from 'src/integrations/event-emitter/types/ob import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/object-record-create.event'; import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event'; import { WorkspacePreQueryHookService } from 'src/workspace/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface'; import { PGGraphQLMutation, PGGraphQLResult, } from './interfaces/pg-graphql.interface'; +import { computePgGraphQLError } from './utils/compute-pg-graphql-error.util'; @Injectable() export class WorkspaceQueryRunnerService { @@ -55,6 +52,7 @@ export class WorkspaceQueryRunnerService { private readonly messageQueueService: MessageQueueService, private readonly eventEmitter: EventEmitter2, private readonly workspacePreQueryHookService: WorkspacePreQueryHookService, + private readonly environmentService: EnvironmentService, ) {} async findMany< @@ -218,10 +216,12 @@ export class WorkspaceQueryRunnerService { options: WorkspaceQueryRunnerOptions, ): Promise { const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.updateMany( - args, - options, - ); + const maximumRecordAffected = + this.environmentService.getMutationMaximumRecordAffected(); + const query = await this.workspaceQueryBuilderFactory.updateMany(args, { + ...options, + atMost: maximumRecordAffected, + }); const result = await this.execute(query, workspaceId); @@ -248,10 +248,12 @@ export class WorkspaceQueryRunnerService { options: WorkspaceQueryRunnerOptions, ): Promise { const { workspaceId, objectMetadataItem } = options; - const query = await this.workspaceQueryBuilderFactory.deleteMany( - args, - options, - ); + const maximumRecordAffected = + this.environmentService.getMutationMaximumRecordAffected(); + const query = await this.workspaceQueryBuilderFactory.deleteMany(args, { + ...options, + atMost: maximumRecordAffected, + }); const result = await this.execute(query, workspaceId); @@ -337,16 +339,16 @@ export class WorkspaceQueryRunnerService { ); await workspaceDataSource?.query(` - SET search_path TO ${this.workspaceDataSourceService.getSchemaName( - workspaceId, - )}; - `); + SET search_path TO ${this.workspaceDataSourceService.getSchemaName( + workspaceId, + )}; + `); const results = await workspaceDataSource?.query(` - SELECT graphql.resolve($$ - ${query} - $$); - `); + SELECT graphql.resolve($$ + ${query} + $$); + `); return results; } @@ -363,11 +365,13 @@ export class WorkspaceQueryRunnerService { const errors = graphqlResult?.[0]?.resolve?.errors; if (errors && errors.length > 0) { - throw new InternalServerErrorException( - `GraphQL errors on ${command}${ - objectMetadataItem.nameSingular - }: ${JSON.stringify(errors)}`, + const error = computePgGraphQLError( + command, + objectMetadataItem.nameSingular, + errors, ); + + throw error; } return parseResult(result);