diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception.ts index 4f279828ca..5104ac6d74 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception.ts @@ -19,4 +19,6 @@ export enum GraphqlQueryRunnerExceptionCode { RELATION_TARGET_OBJECT_METADATA_NOT_FOUND = 'RELATION_TARGET_OBJECT_METADATA_NOT_FOUND', NOT_IMPLEMENTED = 'NOT_IMPLEMENTED', INVALID_POST_HOOK_PAYLOAD = 'INVALID_POST_HOOK_PAYLOAD', + UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT = 'UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT', + UPSERT_MAX_RECORDS_EXCEEDED = 'UPSERT_MAX_RECORDS_EXCEEDED', } 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 30350f86eb..4760591166 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,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { t } from '@lingui/core/macro'; import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; import { capitalize, isDefined } from 'twenty-shared/utils'; import { @@ -41,6 +42,16 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol async resolve( executionArgs: GraphqlQueryResolverExecutionArgs, ): Promise { + if (executionArgs.args.data.length > QUERY_MAX_RECORDS) { + throw new GraphqlQueryRunnerException( + `Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`, + GraphqlQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED, + { + userFriendlyMessage: t`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`, + }, + ); + } + const { objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; @@ -284,12 +295,36 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol const recordsToInsert: Partial[] = []; for (const record of records) { - let existingRecord: PartialObjectRecordWithId | null = null; + const matchingRecordId = this.getMatchingRecordId( + record, + conflictingFields, + existingRecords, + ); - for (const field of conflictingFields) { + if (isDefined(matchingRecordId)) { + recordsToUpdate.push({ ...record, id: matchingRecordId }); + } else { + recordsToInsert.push(record); + } + } + + return { recordsToUpdate, recordsToInsert }; + } + + private getMatchingRecordId( + record: Partial, + conflictingFields: { + baseField: string; + fullPath: string; + column: string; + }[], + existingRecords: PartialObjectRecordWithId[], + ): string | undefined { + const matchingRecordIds = conflictingFields.reduce( + (acc, field) => { const requestFieldValue = this.getValueFromPath(record, field.fullPath); - const existingRec = existingRecords.find((existingRecord) => { + const matchingRecord = existingRecords.find((existingRecord) => { const existingFieldValue = this.getValueFromPath( existingRecord, field.fullPath, @@ -301,20 +336,35 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol ); }); - if (existingRec) { - existingRecord = { ...record, id: existingRec.id }; - break; + if (isDefined(matchingRecord)) { + acc.push(matchingRecord.id); } - } - if (existingRecord) { - recordsToUpdate.push({ ...record, id: existingRecord.id }); - } else { - recordsToInsert.push(record); - } + return acc; + }, + [], + ); + + if ([...new Set(matchingRecordIds)].length > 1) { + const conflictingFieldsValues = conflictingFields + .map((field) => { + const value = this.getValueFromPath(record, field.fullPath); + + return isDefined(value) ? `${field.fullPath}: ${value}` : undefined; + }) + .filter(isDefined) + .join(', '); + + throw new GraphqlQueryRunnerException( + `Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`, + GraphqlQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT, + { + userFriendlyMessage: t`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`, + }, + ); } - return { recordsToUpdate, recordsToInsert }; + return matchingRecordIds[0]; } private async processRecordsToUpdate({ @@ -400,6 +450,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol .where({ id: In(objectRecords.generatedMaps.map((record) => record.id)), }) + .withDeleted() .take(QUERY_MAX_RECORDS) .getMany(); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util.ts index f98ffc39d0..989097f24f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util.ts @@ -24,6 +24,8 @@ export const graphqlQueryRunnerExceptionHandler = ( case GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND: case GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT: case GraphqlQueryRunnerExceptionCode.NOT_IMPLEMENTED: + case GraphqlQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT: + case GraphqlQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED: throw new UserInputError(error); case GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND: throw new NotFoundError(error); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/calendar-events.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/calendar-events.integration-spec.ts index 55150122d6..61da6f9ef7 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/calendar-events.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/calendar-events.integration-spec.ts @@ -2,6 +2,7 @@ import { CALENDAR_EVENT_GQL_FIELDS } from 'test/integration/constants/calendar-e 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 { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; import { CALENDAR_EVENT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/calendar-event-data-seeds.constant'; @@ -22,7 +23,7 @@ describe('calendarEventsResolver (e2e)', () => { const edges = data.edges; - expect(edges.length).toEqual(60); + expect(edges.length).toEqual(QUERY_MAX_RECORDS); const calendarEvent = edges[0].node; diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/messages.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/messages.integration-spec.ts index c5864b8433..d24e0f880b 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/messages.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/messages.integration-spec.ts @@ -2,6 +2,7 @@ import { MESSAGE_GQL_FIELDS } from 'test/integration/constants/message-gql-field 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 { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; import { MESSAGE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-data-seeds.constant'; @@ -22,7 +23,7 @@ describe('messagesResolver (e2e)', () => { const edges = data.edges; - expect(edges.length).toEqual(60); + expect(edges.length).toEqual(QUERY_MAX_RECORDS); const message1 = edges[0].node; diff --git a/packages/twenty-server/test/integration/graphql/suites/upsert/upsert.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/upsert/upsert.integration-spec.ts new file mode 100644 index 0000000000..6919f9c917 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/upsert/upsert.integration-spec.ts @@ -0,0 +1,260 @@ +import gql from 'graphql-tag'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +const createRecordsQuery = gql` + mutation CreateRecords( + $data: [TestRecordObjectCreateInput!] + $upsert: Boolean + ) { + createTestRecordObjects(data: $data, upsert: $upsert) { + id + firstUniqueTestField + secondUniqueTestField + name + deletedAt + } + } +`; + +const deleteRecordsQuery = gql` + mutation DeleteRecords($filter: TestRecordObjectFilterInput!) { + deleteTestRecordObjects(filter: $filter) { + id + firstUniqueTestField + secondUniqueTestField + name + deletedAt + } + } +`; + +describe('upsert (createMany with upsert:true)', () => { + let createdObjectMetadataId = ''; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'testRecordObject', + namePlural: 'testRecordObjects', + labelSingular: 'Test Record Object', + labelPlural: 'Test Record Objects', + icon: 'IconTestRecord', + }, + }); + + createdObjectMetadataId = objectMetadataId; + + await createOneFieldMetadata({ + input: { + name: 'firstUniqueTestField', + label: 'First Unique Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + isUnique: true, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + await createOneFieldMetadata({ + input: { + name: 'secondUniqueTestField', + label: 'Second Unique Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectMetadataId, + isUnique: true, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + }); + + afterEach(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + }); + + it('should update many records', async () => { + // Create 2 records + await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + firstUniqueTestField: 'firstUniqueTestField1', + secondUniqueTestField: 'secondUniqueTestField1', + name: 'record1', + }, + { + firstUniqueTestField: 'firstUniqueTestField2', + secondUniqueTestField: 'secondUniqueTestField2', + name: 'record2', + }, + ], + upsert: false, + }, + }); + + // Update 2 records using upsert + const updatedRecordsResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + firstUniqueTestField: 'firstUniqueTestField1', + name: 'updatedRecord1', + }, + { + firstUniqueTestField: 'firstUniqueTestField2', + name: 'updatedRecord2', + }, + ], + upsert: true, + }, + }); + + const updatedRecords = + updatedRecordsResponse.body.data.createTestRecordObjects; + + expect(updatedRecords).toHaveLength(2); + + const record1 = updatedRecords.find( + (record: any) => record.firstUniqueTestField === 'firstUniqueTestField1', + ); + const record2 = updatedRecords.find( + (record: any) => record.firstUniqueTestField === 'firstUniqueTestField2', + ); + + expect(record1).toEqual({ + id: expect.any(String), + firstUniqueTestField: 'firstUniqueTestField1', + secondUniqueTestField: 'secondUniqueTestField1', + name: 'updatedRecord1', + deletedAt: null, + }); + + expect(record2).toEqual({ + id: expect.any(String), + firstUniqueTestField: 'firstUniqueTestField2', + secondUniqueTestField: 'secondUniqueTestField2', + name: 'updatedRecord2', + deletedAt: null, + }); + }); + + it('should update soft-deleted records', async () => { + // Create a record + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + firstUniqueTestField: 'softDeletedRecord', + secondUniqueTestField: 'softDeletedSecondField', + name: 'originalRecord', + }, + ], + upsert: false, + }, + }); + + const createdRecord = createResponse.body.data.createTestRecordObjects[0]; + + // Soft delete the record + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { + id: { + eq: createdRecord.id, + }, + }, + }, + }); + + const upsertResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + firstUniqueTestField: 'softDeletedRecord', + name: 'restoredRecord', + }, + ], + upsert: true, + }, + }); + + const upsertedRecord = upsertResponse.body.data.createTestRecordObjects[0]; + + expect(upsertedRecord).toEqual({ + id: createdRecord.id, + firstUniqueTestField: 'softDeletedRecord', + secondUniqueTestField: 'softDeletedSecondField', + name: 'restoredRecord', + deletedAt: expect.any(String), + }); + }); + + it('should throw an error when multiple records with the same unique field values are found', async () => { + await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + firstUniqueTestField: 'firstUniqueTestField1', + secondUniqueTestField: 'secondUniqueTestField1', + name: 'record1', + }, + { + firstUniqueTestField: 'firstUniqueTestField2', + secondUniqueTestField: 'secondUniqueTestField2', + name: 'record2', + }, + ], + upsert: false, + }, + }); + + const upsertResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + firstUniqueTestField: 'firstUniqueTestField1', + secondUniqueTestField: 'secondUniqueTestField2', + name: 'conflictingRecord', + }, + ], + upsert: true, + }, + }); + + expect(upsertResponse.body.errors).toBeDefined(); + expect(upsertResponse.body.errors[0].message).toContain( + 'Multiple records found with the same unique field values', + ); + expect(upsertResponse.body.errors[0].extensions.code).toBe( + 'BAD_USER_INPUT', + ); + }); +}); diff --git a/packages/twenty-shared/src/constants/QueryMaxRecords.ts b/packages/twenty-shared/src/constants/QueryMaxRecords.ts index e9e1c71915..fd3dd5535a 100644 --- a/packages/twenty-shared/src/constants/QueryMaxRecords.ts +++ b/packages/twenty-shared/src/constants/QueryMaxRecords.ts @@ -1 +1 @@ -export const QUERY_MAX_RECORDS = 60; +export const QUERY_MAX_RECORDS = 200;