From 3c485ccb3718e9fbea28683c8f49ee23540e638f Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Mon, 4 Aug 2025 19:06:40 +0200 Subject: [PATCH] Upsert in CreateMany - fixes (#13598) fixes https://github.com/twentyhq/twenty/issues/13553 and https://github.com/twentyhq/core-team-issues/issues/1300 --- ...phql-query-create-many-resolver.service.ts | 31 ++-- .../utils/handle-duplicate-key-error.util.ts | 12 +- .../constants/person-gql-fields.constants.ts | 3 + .../people-create-many.integration-spec.ts | 166 ++++++++++++++++++ 4 files changed, 190 insertions(+), 22 deletions(-) create mode 100644 packages/twenty-server/test/integration/graphql/suites/object-generated/people-create-many.integration-spec.ts 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 c9ff1a5873..6bb5acee4f 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 @@ -89,19 +89,12 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol ): Promise { const { objectMetadataItemWithFieldMaps } = executionArgs.options; - const selectedColumns = buildColumnsToSelect({ - select: executionArgs.graphqlQuerySelectedFieldsResult.select, - relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, - objectMetadataItemWithFieldMaps, - }); - const conflictingFields = this.getConflictingFields( objectMetadataItemWithFieldMaps, ); const existingRecords = await this.findExistingRecords( executionArgs, conflictingFields, - selectedColumns, ); const { recordsToUpdate, recordsToInsert } = this.categorizeRecords( @@ -187,7 +180,6 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol fullPath: string; column: string; }[], - selectedColumns: Record, ): Promise[]> { const { objectMetadataItemWithFieldMaps } = executionArgs.options; const queryBuilder = executionArgs.repository.createQueryBuilder( @@ -203,12 +195,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol queryBuilder.orWhere(condition); }); - return await queryBuilder - .setFindOptions({ - select: selectedColumns, - }) - .withDeleted() - .getMany(); + return await queryBuilder.withDeleted().getMany(); } private getValueFromPath( @@ -271,11 +258,17 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol for (const field of conflictingFields) { const requestFieldValue = this.getValueFromPath(record, field.fullPath); - const existingRec = existingRecords.find( - (existingRecord) => - isDefined(existingRecord[field.column]) && - existingRecord[field.column] === requestFieldValue, - ); + const existingRec = existingRecords.find((existingRecord) => { + const existingFieldValue = this.getValueFromPath( + existingRecord, + field.fullPath, + ); + + return ( + isDefined(existingFieldValue) && + existingFieldValue === requestFieldValue + ); + }); if (existingRec) { existingRecord = { ...record, id: existingRec.id }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts index 2f94584763..26d9973b21 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts @@ -5,12 +5,18 @@ import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-qu import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +interface PostgreSQLError extends QueryFailedError { + detail?: string; +} + export const handleDuplicateKeyError = ( - error: QueryFailedError, + error: PostgreSQLError, context: WorkspaceQueryRunnerOptions, ) => { const indexNameMatch = error.message.match(/"([^"]+)"/); + const duplicatedValues = error?.detail?.match(/=\(([^)]+)\)/)?.[1]; + if (indexNameMatch) { const indexName = indexNameMatch[1]; @@ -42,9 +48,9 @@ export const handleDuplicateKeyError = ( if (affectedColumns?.length === 1) { throw new UserInputError( - `Duplicate ${columnNames}. Please set a unique one.`, + `Duplicate ${columnNames} ${duplicatedValues ? `with value ${duplicatedValues}` : ''}. Please set a unique one.`, { - userFriendlyMessage: `This ${columnNames.toLowerCase()} is already taken. Please choose a different value.`, + userFriendlyMessage: `This ${columnNames.toLowerCase()} ${duplicatedValues ? `with value ${duplicatedValues}` : ''} is already taken. Please choose a different value.`, }, ); } diff --git a/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts index 5c6e0deee5..2aa86e4167 100644 --- a/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts +++ b/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts @@ -9,6 +9,9 @@ export const PERSON_GQL_FIELDS = ` firstName lastName } + emails { + primaryEmail + } createdAt deletedAt `; diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/people-create-many.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/people-create-many.integration-spec.ts new file mode 100644 index 0000000000..2f755031ab --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/people-create-many.integration-spec.ts @@ -0,0 +1,166 @@ +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; +import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; + +describe('people resolvers (integration)', () => { + let person2Id: string; + + beforeAll(async () => { + await deleteAllRecords('person'); + }); + + it('should create many people', async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + name: { + firstName: 'John', + lastName: 'Doe', + }, + jobTitle: 'Just Created', + emails: { + primaryEmail: 'john.doe@example.com', + }, + }, + { + name: { + firstName: 'Jane', + lastName: 'Smith', + }, + jobTitle: 'Just Created', + emails: { + primaryEmail: 'jane.smith@example.com', + }, + }, + { + name: { + firstName: 'Tim', + lastName: 'Apple', + }, + jobTitle: 'Just Created', + emails: { + primaryEmail: 'tim.apple@example.com', + }, + }, + ], + upsert: true, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.createPeople).toHaveLength(3); + expect(response.body.errors).toBeUndefined(); + }); + + it('should update many people', async () => { + const findOneOperation = findOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + filter: { + emails: { + primaryEmail: { + eq: 'jane.smith@example.com', + }, + }, + }, + }); + + const findOneResponse = await makeGraphqlAPIRequest(findOneOperation); + + person2Id = findOneResponse.body.data.person.id; + + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + emails: { + primaryEmail: 'john.doe@example.com', + }, + jobTitle: 'Just Updated', + }, + { + id: person2Id, + jobTitle: 'Just Updated', + }, + ], + upsert: true, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + const findAllOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + }); + + const findAllResponse = await makeGraphqlAPIRequest(findAllOperation); + + expect(findAllResponse.body.data.people.edges.length).toBe(3); + + expect(response.body.data.createPeople).toHaveLength(2); + expect(response.body.errors).toBeUndefined(); + + response.body.data.createPeople.forEach((person: any) => { + expect(person.jobTitle).toEqual('Just Updated'); + }); + }); + + it('should update and create many people', async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + emails: { + primaryEmail: 'tim.apple@example.com', + }, + jobTitle: 'Just Updated', + }, + { + jobTitle: 'Just Created', + emails: { + primaryEmail: 'paul.doe@example.com', + }, + }, + { + id: person2Id, + jobTitle: 'Email Just Updated', + emails: { + primaryEmail: 'jane.smith@updated.com', + }, + }, + ], + upsert: true, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + const findAllOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + }); + + const findAllResponse = await makeGraphqlAPIRequest(findAllOperation); + + expect(findAllResponse.body.data.people.edges.length).toBe(4); + + expect(response.body.data.createPeople).toHaveLength(3); + expect( + response.body.data.createPeople.find( + (person: any) => person.id === person2Id, + ).emails.primaryEmail, + ).toEqual('jane.smith@updated.com'); + expect(response.body.errors).toBeUndefined(); + }); +});