diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts new file mode 100644 index 00000000000..768fe558518 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts @@ -0,0 +1,436 @@ +import { Injectable } from '@nestjs/common'; + +import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; +import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm'; + +import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; +import { PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; +import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util'; +import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util'; +import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util'; +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +import { + CommonQueryNames, + CreateManyQueryArgs, +} from 'src/engine/api/common/types/common-query-args.type'; +import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; +import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; +import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; +import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; + +@Injectable() +export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerService { + async run({ + args, + authContext: toValidateAuthContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }: { + args: CreateManyQueryArgs; + authContext: AuthContext; + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + }): Promise { + const authContext = toValidateAuthContext; + + if (!isWorkspaceAuthContext(authContext)) { + throw new CommonQueryRunnerException( + 'Invalid auth context', + CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT, + ); + } + assertMutationNotOnRemoteObject(objectMetadataItemWithFieldMaps); + + // TODO : Refacto-common - Remove this validation once https://github.com/twentyhq/core-team-issues/issues/1622 done + args.data.forEach((record) => { + if (record?.id) { + assertIsValidUuid(record.id); + } + }); + + const { + workspaceDataSource, + repository, + roleId, + shouldBypassPermissionChecks, + } = await this.prepareQueryRunnerContext({ + authContext, + objectMetadataItemWithFieldMaps, + }); + + const processedArgs = await this.processQueryArgs({ + authContext, + objectMetadataItemWithFieldMaps, + args, + }); + + const objectRecords = await this.insertOrUpsertRecords({ + repository, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + args: processedArgs, + }); + + const upsertedRecords = await this.fetchUpsertedRecords({ + args: processedArgs, + objectRecords, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + repository, + }); + + await this.processNestedRelationsIfNeeded({ + args: processedArgs, + records: upsertedRecords, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + roleId, + authContext, + workspaceDataSource, + shouldBypassPermissionChecks, + }); + + return upsertedRecords; + } + + async processQueryArgs({ + authContext, + objectMetadataItemWithFieldMaps, + args, + }: { + authContext: WorkspaceAuthContext; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + args: CreateManyQueryArgs; + }): Promise { + const hookedArgs = + (await this.workspaceQueryHookService.executePreQueryHooks( + authContext, + objectMetadataItemWithFieldMaps.nameSingular, + CommonQueryNames.createMany, + args, + //TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency + )) as CreateManyQueryArgs; + + return { + ...hookedArgs, + data: await this.queryRunnerArgsFactory.overrideDataByFieldMetadata({ + partialRecordInputs: hookedArgs.data, + authContext, + objectMetadataItemWithFieldMaps, + }), + }; + } + + private async insertOrUpsertRecords({ + repository, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + args, + }: { + repository: WorkspaceRepository; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + objectMetadataMaps: ObjectMetadataMaps; + args: CreateManyQueryArgs; + }): Promise { + if (!args.upsert) { + const selectedColumns = buildColumnsToReturn({ + select: args.selectedFieldsResult.select, + relations: args.selectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + }); + + return await repository.insert(args.data, undefined, selectedColumns); + } + + return this.performUpsertOperation({ + repository, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + args, + }); + } + + private async performUpsertOperation({ + repository, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + args, + }: { + repository: WorkspaceRepository; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + objectMetadataMaps: ObjectMetadataMaps; + args: CreateManyQueryArgs; + }): Promise { + const conflictingFields = getConflictingFields( + objectMetadataItemWithFieldMaps, + ); + const existingRecords = await this.findExistingRecords({ + repository, + objectMetadataItemWithFieldMaps, + args, + conflictingFields, + }); + + const { recordsToUpdate, recordsToInsert } = categorizeRecords( + args.data, + conflictingFields, + existingRecords, + ); + + const result: InsertResult = { + identifiers: [], + generatedMaps: [], + raw: [], + }; + + const columnsToReturn = buildColumnsToReturn({ + select: args.selectedFieldsResult.select, + relations: args.selectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + }); + + if (recordsToUpdate.length > 0) { + await this.processRecordsToUpdate({ + partialRecordsToUpdate: recordsToUpdate, + repository, + objectMetadataItemWithFieldMaps, + result, + columnsToReturn, + }); + } + + await this.processRecordsToInsert({ + recordsToInsert, + repository, + result, + columnsToReturn, + }); + + return result; + } + + private async findExistingRecords({ + repository, + objectMetadataItemWithFieldMaps, + args, + conflictingFields, + }: { + repository: WorkspaceRepository; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + args: CreateManyQueryArgs; + conflictingFields: { + baseField: string; + fullPath: string; + column: string; + }[]; + }): Promise { + const queryBuilder = repository.createQueryBuilder( + objectMetadataItemWithFieldMaps.nameSingular, + ); + + const whereConditions = buildWhereConditions(args.data, conflictingFields); + + whereConditions.forEach((condition) => { + queryBuilder.orWhere(condition); + }); + + const restrictedFields = + repository.objectRecordsPermissions?.[objectMetadataItemWithFieldMaps.id] + ?.restrictedFields; + + const selectOptions = getAllSelectableFields({ + restrictedFields: restrictedFields ?? {}, + objectMetadata: { + objectMetadataMapItem: objectMetadataItemWithFieldMaps, + }, + }); + + return (await queryBuilder + .withDeleted() + .setFindOptions({ + select: selectOptions, + }) + .getMany()) as PartialObjectRecordWithId[]; + } + + private async processRecordsToUpdate({ + partialRecordsToUpdate, + repository, + objectMetadataItemWithFieldMaps, + result, + columnsToReturn, + }: { + partialRecordsToUpdate: PartialObjectRecordWithId[]; + repository: WorkspaceRepository; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + result: InsertResult; + columnsToReturn: string[]; + }): Promise { + const partialRecordsToUpdateWithoutCreatedByUpdate = + partialRecordsToUpdate.map((record) => + this.getRecordWithoutCreatedBy(record, objectMetadataItemWithFieldMaps), + ); + + const savedRecords = await repository.updateMany( + partialRecordsToUpdateWithoutCreatedByUpdate.map((record) => ({ + criteria: record.id, + partialEntity: { ...record, deletedAt: null }, + })), + undefined, + columnsToReturn, + ); + + result.identifiers.push( + ...savedRecords.generatedMaps.map((record) => ({ id: record.id })), + ); + result.generatedMaps.push( + ...savedRecords.generatedMaps.map((record) => ({ id: record.id })), + ); + } + + private async processRecordsToInsert({ + recordsToInsert, + repository, + result, + columnsToReturn, + }: { + recordsToInsert: Partial[]; + repository: WorkspaceRepository; + result: InsertResult; + columnsToReturn: string[]; + }): Promise { + if (recordsToInsert.length > 0) { + const insertResult = await repository.insert( + recordsToInsert, + undefined, + columnsToReturn, + ); + + result.identifiers.push(...insertResult.identifiers); + result.generatedMaps.push(...insertResult.generatedMaps); + result.raw.push(...insertResult.raw); + } + } + + private async fetchUpsertedRecords({ + args, + objectRecords, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + repository, + }: { + args: CreateManyQueryArgs; + objectRecords: InsertResult; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + objectMetadataMaps: ObjectMetadataMaps; + repository: WorkspaceRepository; + }): Promise { + const queryBuilder = repository.createQueryBuilder( + objectMetadataItemWithFieldMaps.nameSingular, + ); + + const columnsToSelect = buildColumnsToSelect({ + select: args.selectedFieldsResult.select, + relations: args.selectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + }); + + const upsertedRecords = await queryBuilder + .setFindOptions({ + select: columnsToSelect, + }) + .where({ + id: In(objectRecords.generatedMaps.map((record) => record.id)), + }) + .withDeleted() + .take(QUERY_MAX_RECORDS) + .getMany(); + + return upsertedRecords as ObjectRecord[]; + } + + private async processNestedRelationsIfNeeded({ + args, + records, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + roleId, + authContext, + workspaceDataSource, + shouldBypassPermissionChecks, + }: { + args: CreateManyQueryArgs; + records: ObjectRecord[]; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + objectMetadataMaps: ObjectMetadataMaps; + roleId?: string; + authContext: AuthContext; + workspaceDataSource: WorkspaceDataSource; + shouldBypassPermissionChecks: boolean; + }): Promise { + if (!args.selectedFieldsResult.relations) { + return; + } + + await this.processNestedRelationsHelper.processNestedRelations({ + objectMetadataMaps, + parentObjectMetadataItem: objectMetadataItemWithFieldMaps, + parentObjectRecords: records, + //TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common + relations: args.selectedFieldsResult.relations as Record< + string, + FindOptionsRelations + >, + limit: QUERY_MAX_RECORDS, + authContext, + workspaceDataSource, + roleId, + shouldBypassPermissionChecks, + selectedFields: args.selectedFieldsResult.select, + }); + } + + private getRecordWithoutCreatedBy( + record: PartialObjectRecordWithId, + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, + ): Omit { + let recordWithoutCreatedByUpdate = record; + + const createdByFieldMetadataId = + objectMetadataItemWithFieldMaps.fieldIdByName['createdBy']; + const createdByFieldMetadata = + objectMetadataItemWithFieldMaps.fieldsById[createdByFieldMetadataId]; + + if (!isDefined(createdByFieldMetadata)) { + throw new CommonQueryRunnerException( + `Missing createdBy field metadata for object ${objectMetadataItemWithFieldMaps.nameSingular}`, + CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD, + ); + } + + if ('createdBy' in record && createdByFieldMetadata.isCustom === false) { + const { createdBy: _createdBy, ...recordWithoutCreatedBy } = record; + + recordWithoutCreatedByUpdate = recordWithoutCreatedBy; + } + + return recordWithoutCreatedByUpdate; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type.ts new file mode 100644 index 00000000000..3b92e5c298c --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type.ts @@ -0,0 +1,3 @@ +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +export type PartialObjectRecordWithId = Partial & { id: string }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts new file mode 100644 index 00000000000..68a5f74f30e --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/build-where-conditions.util.spec.ts @@ -0,0 +1,110 @@ +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util'; + +describe('buildWhereConditions', () => { + const records: Partial[] = [ + { + id: 'record-1', + uniqueText: 'alpha', + emailsField: { primaryEmail: 'alpha@example.com' }, + }, + { + id: 'record-2', + uniqueText: 'beta', + emailsField: { primaryEmail: 'beta@example.com' }, + }, + { + id: 'record-3', + // uniqueText intentionally missing to validate filtering of undefined + emailsField: { primaryEmail: undefined }, + }, + ]; + + it('returns empty array when no conflicting fields provided', () => { + const where = buildWhereConditions(records, []); + + expect(where).toEqual([]); + }); + + it('builds a single where condition for a flat field using all defined values', () => { + const where = buildWhereConditions(records, [ + { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, + ]); + + expect(where).toHaveLength(1); + const condition = where[0]; + + expect(Object.keys(condition)).toEqual(['uniqueText']); + + const operator = condition.uniqueText; + + expect(operator.type.toLowerCase()).toBe('in'); + expect(operator.value).toEqual(['alpha', 'beta']); + }); + + it('skips adding a condition when all values for a field are undefined', () => { + const where = buildWhereConditions( + [{ id: '1' }, { id: '2' }], + [ + { + baseField: 'uniqueText', + fullPath: 'uniqueText', + column: 'uniqueText', + }, + ], + ); + + expect(where).toEqual([]); + }); + + it('builds conditions for nested paths', () => { + const where = buildWhereConditions(records, [ + { + baseField: 'emailsField', + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ]); + + expect(where).toHaveLength(1); + const condition = where[0]; + + expect(Object.keys(condition)).toEqual(['emailsFieldPrimaryEmail']); + + const operator = condition.emailsFieldPrimaryEmail; + + expect(operator.type.toLowerCase()).toBe('in'); + expect(operator.value).toEqual(['alpha@example.com', 'beta@example.com']); + }); + + it('builds multiple conditions when multiple conflicting fields are provided', () => { + const where = buildWhereConditions(records, [ + { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, + { + baseField: 'emailsField', + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ]); + + expect(where).toHaveLength(2); + + expect(where.map((condition) => Object.keys(condition)[0]).sort()).toEqual([ + 'emailsFieldPrimaryEmail', + 'uniqueText', + ]); + + const uniqueTextOperator = where.find((c) => 'uniqueText' in c)?.uniqueText; + + const emailOperator = where.find( + (c) => 'emailsFieldPrimaryEmail' in c, + )?.emailsFieldPrimaryEmail; + + expect(uniqueTextOperator?.value).toEqual(['alpha', 'beta']); + expect(emailOperator?.value).toEqual([ + 'alpha@example.com', + 'beta@example.com', + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts new file mode 100644 index 00000000000..839c05a554f --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/categorize-records.util.spec.ts @@ -0,0 +1,107 @@ +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; +import { categorizeRecords } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util'; + +describe('categorizeRecords', () => { + const conflictingFields = [ + { baseField: 'id', fullPath: 'id', column: 'id' }, + { + baseField: 'uniqueText', + fullPath: 'uniqueText', + column: 'uniqueText', + }, + { + baseField: 'emailsField', + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ]; + + const existingRecords: PartialObjectRecordWithId[] = [ + { + id: 'r1', + uniqueText: 'alpha', + emailsField: { primaryEmail: 'alpha@example.com' }, + }, + { + id: 'r2', + uniqueText: 'beta', + emailsField: { primaryEmail: 'beta@example.com' }, + }, + ]; + + it('return records to insert only', () => { + const records: Partial[] = [ + { uniqueText: 'gamma' }, + { emailsField: { primaryEmail: 'nobody@example.com' } }, + ]; + + const { recordsToInsert, recordsToUpdate } = categorizeRecords( + records, + conflictingFields, + existingRecords, + ); + + expect(recordsToUpdate).toHaveLength(0); + expect(recordsToInsert).toHaveLength(2); + expect(recordsToInsert).toEqual(records); + }); + + it('return records to update only', () => { + const records: Partial[] = [ + { uniqueText: 'alpha', name: 'Updated A' }, + { emailsField: { primaryEmail: 'beta@example.com' }, name: 'Updated B' }, + ]; + + const { recordsToInsert, recordsToUpdate } = categorizeRecords( + records, + conflictingFields, + existingRecords, + ); + + expect(recordsToInsert).toHaveLength(0); + expect(recordsToUpdate).toHaveLength(2); + + const ids = recordsToUpdate.map((r) => r.id); + + expect(ids.sort()).toEqual(['r1', 'r2']); + + expect(recordsToUpdate).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'r1', + name: 'Updated A', + uniqueText: 'alpha', + }), + expect.objectContaining({ id: 'r2', name: 'Updated B' }), + ]), + ); + }); + + it('return records to insert and update', () => { + const records: Partial[] = [ + { uniqueText: 'alpha' }, + { uniqueText: 'gamma' }, + { emailsField: { primaryEmail: 'beta@example.com' } }, + ]; + + const { recordsToInsert, recordsToUpdate } = categorizeRecords( + records, + conflictingFields, + existingRecords, + ); + + expect(recordsToUpdate).toHaveLength(2); + expect(recordsToInsert).toHaveLength(1); + + expect(recordsToInsert[0]).toEqual({ uniqueText: 'gamma' }); + + expect(recordsToUpdate).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'r1', uniqueText: 'alpha' }), + expect.objectContaining({ id: 'r2' }), + ]), + ); + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts new file mode 100644 index 00000000000..ce8758e9a84 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-conflicting-fields.util.spec.ts @@ -0,0 +1,139 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { objectMetadataMapItemMock } from 'src/engine/api/__mocks__/object-metadata-item.mock'; +import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util'; +import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock'; + +describe('getConflictingFields', () => { + const workspaceId = 'workspaceId'; + const objectMetadataId = 'objectMetadataId'; + + const idField = getMockFieldMetadataEntity({ + workspaceId, + objectMetadataId, + id: 'id-field-id', + name: 'id', + type: FieldMetadataType.UUID, + isUnique: true, + }); + + const uniqueTextField = getMockFieldMetadataEntity({ + workspaceId, + objectMetadataId, + id: 'unique-text-id', + name: 'uniqueText', + type: FieldMetadataType.TEXT, + isUnique: true, + }); + + const emailsUniqueField = getMockFieldMetadataEntity({ + workspaceId, + objectMetadataId, + id: 'emails-unique-id', + name: 'emailsField', + type: FieldMetadataType.EMAILS, + isUnique: true, + }); + + const phonesNotUniqueField = getMockFieldMetadataEntity({ + workspaceId, + objectMetadataId, + id: 'phones-not-unique-id', + name: 'phonesField', + type: FieldMetadataType.PHONES, + isUnique: false, + }); + + const addressUniqueFieldNoIncludedProp = getMockFieldMetadataEntity({ + workspaceId, + objectMetadataId, + id: 'address-unique-id', + name: 'addressField', + type: FieldMetadataType.ADDRESS, + isUnique: true, + }); + + const buildObjectMetadataWithFields = ( + fields: (typeof idField)[], + ): ObjectMetadataItemWithFieldMaps => { + const fieldsById = fields.reduce>( + (acc, field) => { + acc[field.id] = field; + + return acc; + }, + {}, + ); + + return { + ...objectMetadataMapItemMock, + fieldsById, + fieldIdByName: Object.fromEntries( + Object.values(fieldsById).map((f) => [f.name, f.id]), + ), + } as ObjectMetadataItemWithFieldMaps; + }; + + it('returns id and unique non-composite fields as conflicts', () => { + const objectMetadata = buildObjectMetadataWithFields([ + idField, + uniqueTextField, + ]); + + const result = getConflictingFields(objectMetadata); + + expect(result).toEqual( + expect.arrayContaining([ + { baseField: 'id', fullPath: 'id', column: 'id' }, + { + baseField: 'uniqueText', + fullPath: 'uniqueText', + column: 'uniqueText', + }, + ]), + ); + }); + + it('returns composite field with included unique property using full path and computed column', () => { + const objectMetadata = buildObjectMetadataWithFields([ + idField, + emailsUniqueField, + ]); + + const result = getConflictingFields(objectMetadata); + + expect(result).toEqual( + expect.arrayContaining([ + { baseField: 'id', fullPath: 'id', column: 'id' }, + { + baseField: 'emailsField', + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ]), + ); + }); + + it('does not include composite fields without included unique property', () => { + const objectMetadata = buildObjectMetadataWithFields([ + idField, + addressUniqueFieldNoIncludedProp, + ]); + + const result = getConflictingFields(objectMetadata); + + expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]); + }); + + it('ignores non-unique fields', () => { + const objectMetadata = buildObjectMetadataWithFields([ + idField, + phonesNotUniqueField, + ]); + + const result = getConflictingFields(objectMetadata); + + expect(result).toEqual([{ baseField: 'id', fullPath: 'id', column: 'id' }]); + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts new file mode 100644 index 00000000000..7c0b5d7b1c5 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-matching-record-id.util.spec.ts @@ -0,0 +1,98 @@ +import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; +import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util'; +import { CommonQueryRunnerExceptionCode } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; + +describe('getMatchingRecordId', () => { + const existingRecords: PartialObjectRecordWithId[] = [ + { + id: 'recordId1', + uniqueText: 'alpha', + emailsField: { primaryEmail: 'alpha@example.com' }, + }, + { + id: 'recordId2', + uniqueText: 'beta', + emailsField: { primaryEmail: 'beta@example.com' }, + }, + ]; + + it('returns the matching record id when exactly one field matches one existing record', () => { + const record = { + emailsField: { primaryEmail: 'alpha@example.com' }, + }; + + const conflictingFields = [ + { + baseField: 'emailsField', + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ]; + + const id = getMatchingRecordId(record, conflictingFields, existingRecords); + + expect(id).toBe('recordId1'); + }); + + it('returns undefined when no existing record matches any conflicting field', () => { + const record = { + emailsField: { primaryEmail: 'nobody@example.com' }, + }; + + const conflictingFields = [ + { + baseField: 'emailsField', + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ]; + + const id = getMatchingRecordId(record, conflictingFields, existingRecords); + + expect(id).toBeUndefined(); + }); + + it('returns the matching id if multiple conflicting fields point to the same existing record', () => { + const record = { + id: 'recordId1', + uniqueText: 'alpha', + }; + + const conflictingFields = [ + { baseField: 'id', fullPath: 'id', column: 'id' }, + { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, + ]; + + const id = getMatchingRecordId(record, conflictingFields, existingRecords); + + expect(id).toBe('recordId1'); + }); + + it('throws when conflicting fields match different existing records', () => { + const record = { + uniqueText: 'alpha', + emailsField: { primaryEmail: 'beta@example.com' }, + }; + + const conflictingFields = [ + { baseField: 'uniqueText', fullPath: 'uniqueText', column: 'uniqueText' }, + { + baseField: 'emailsField', + fullPath: 'emailsField.primaryEmail', + column: 'emailsFieldPrimaryEmail', + }, + ]; + + expect(() => + getMatchingRecordId(record, conflictingFields, existingRecords), + ).toThrow(); + + try { + getMatchingRecordId(record, conflictingFields, existingRecords); + } catch (error) { + expect(error.code).toBe( + CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT, + ); + } + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-value-from-path.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-value-from-path.util.spec.ts new file mode 100644 index 00000000000..35e8748e558 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/__tests__/get-value-from-path.util.spec.ts @@ -0,0 +1,36 @@ +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util'; + +describe('getValueFromPath', () => { + const baseRecord: Partial = { + id: 'recordId', + name: 'John Doe', + parent: { child: 'nested-value', empty: '' }, + emailsField: { primaryEmail: 'john@example.com' }, + }; + + it('returns direct field value for single-level path', () => { + const value = getValueFromPath(baseRecord, 'name'); + + expect(value).toBe('John Doe'); + }); + + it('returns nested value for two-level path', () => { + const value = getValueFromPath(baseRecord, 'parent.child'); + + expect(value).toBe('nested-value'); + }); + + it('returns undefined when parent field does not exist', () => { + const value = getValueFromPath(baseRecord, 'missing.child'); + + expect(value).toBeUndefined(); + }); + + it('returns undefined when child field does not exist on existing parent', () => { + const value = getValueFromPath(baseRecord, 'parent.missing'); + + expect(value).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util.ts new file mode 100644 index 00000000000..910b4f59af6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util.ts @@ -0,0 +1,29 @@ +import { isDefined } from 'twenty-shared/utils'; +import { type FindOperator, In } from 'typeorm'; + +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util'; + +export const buildWhereConditions = ( + records: Partial[], + conflictingFields: { + baseField: string; + fullPath: string; + column: string; + }[], +): Record>[] => { + const whereConditions: Record>[] = []; + + for (const field of conflictingFields) { + const fieldValues = records + .map((record) => getValueFromPath(record, field.fullPath)) + .filter(isDefined); + + if (fieldValues.length > 0) { + whereConditions.push({ [field.column]: In(fieldValues) }); + } + } + + return whereConditions; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util.ts new file mode 100644 index 00000000000..7084edee13d --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/categorize-records.util.ts @@ -0,0 +1,38 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; +import { getMatchingRecordId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util'; + +export const categorizeRecords = ( + records: Partial[], + conflictingFields: { + baseField: string; + fullPath: string; + column: string; + }[], + existingRecords: PartialObjectRecordWithId[], +): { + recordsToUpdate: PartialObjectRecordWithId[]; + recordsToInsert: Partial[]; +} => { + const recordsToUpdate: PartialObjectRecordWithId[] = []; + const recordsToInsert: Partial[] = []; + + for (const record of records) { + const matchingRecordId = getMatchingRecordId( + record, + conflictingFields, + existingRecords, + ); + + if (isDefined(matchingRecordId)) { + recordsToUpdate.push({ ...record, id: matchingRecordId }); + } else { + recordsToInsert.push(record); + } + } + + return { recordsToUpdate, recordsToInsert }; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts new file mode 100644 index 00000000000..80a0ee79eec --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util.ts @@ -0,0 +1,42 @@ +import { capitalize } from 'twenty-shared/utils'; + +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export const getConflictingFields = ( + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, +): { + baseField: string; + fullPath: string; + column: string; +}[] => { + return Object.values(objectMetadataItemWithFieldMaps.fieldsById) + .filter((field) => field.isUnique || field.name === 'id') + .flatMap((field) => { + const compositeType = compositeTypeDefinitions.get(field.type); + + if (!compositeType) { + return [ + { + baseField: field.name, + fullPath: field.name, + column: field.name, + }, + ]; + } + + const property = compositeType.properties.find( + (prop) => prop.isIncludedInUniqueConstraint, + ); + + return property + ? [ + { + baseField: field.name, + fullPath: `${field.name}.${property.name}`, + column: `${field.name}${capitalize(property.name)}`, + }, + ] + : []; + }); +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts new file mode 100644 index 00000000000..f4f519146f6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-matching-record-id.util.ts @@ -0,0 +1,64 @@ +import { msg } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; + +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; +import { getValueFromPath } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util'; +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; + +export const getMatchingRecordId = ( + record: Partial, + conflictingFields: { + baseField: string; + fullPath: string; + column: string; + }[], + existingRecords: PartialObjectRecordWithId[], +): string | undefined => { + const matchingRecordIds = conflictingFields.reduce((acc, field) => { + const requestFieldValue = getValueFromPath(record, field.fullPath); + + const matchingRecord = existingRecords.find((existingRecord) => { + const existingFieldValue = getValueFromPath( + existingRecord, + field.fullPath, + ); + + return ( + isDefined(existingFieldValue) && + existingFieldValue === requestFieldValue + ); + }); + + if (isDefined(matchingRecord)) { + acc.push(matchingRecord.id); + } + + return acc; + }, []); + + if ([...new Set(matchingRecordIds)].length > 1) { + const conflictingFieldsValues = conflictingFields + .map((field) => { + const value = getValueFromPath(record, field.fullPath); + + return isDefined(value) ? `${field.fullPath}: ${value}` : undefined; + }) + .filter(isDefined) + .join(', '); + + throw new CommonQueryRunnerException( + `Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`, + CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT, + { + userFriendlyMessage: msg`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`, + }, + ); + } + + return matchingRecordIds[0]; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util.ts new file mode 100644 index 00000000000..365547c4a29 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-value-from-path.util.ts @@ -0,0 +1,16 @@ +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +export const getValueFromPath = ( + record: Partial, + path: string, +): string | undefined => { + const pathParts = path.split('.'); + + if (pathParts.length === 1) { + return record[path]; + } + + const [parentField, childField] = pathParts; + + return record[parentField]?.[childField]; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-one-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-one-query-runner.service.ts new file mode 100644 index 00000000000..215395ae5eb --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-one-query-runner.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; +import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; +import { CreateOneQueryArgs } from 'src/engine/api/common/types/common-query-args.type'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; + +@Injectable() +export class CommonCreateOneQueryRunnerService extends CommonBaseQueryRunnerService { + constructor( + private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService, + ) { + super(); + } + + async run({ + args, + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }: { + args: CreateOneQueryArgs; + authContext: AuthContext; + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + }): Promise { + const result = await this.commonCreateManyQueryRunnerService.run({ + args: { + ...args, + data: [args.data], + }, + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }); + + return result[0]; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts index 9723f7e2e85..77cc879f0ea 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts @@ -1,7 +1,11 @@ +import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; +import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service'; import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; export const CommonQueryRunners = [ CommonFindOneQueryRunnerService, CommonFindManyQueryRunnerService, + CommonCreateOneQueryRunnerService, + CommonCreateManyQueryRunnerService, ]; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts index b24ae6eed02..9e394ccd8e5 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts @@ -9,4 +9,6 @@ export enum CommonQueryRunnerExceptionCode { ARGS_CONFLICT = 'ARGS_CONFLICT', INVALID_ARGS_FIRST = 'INVALID_ARGS_FIRST', INVALID_ARGS_LAST = 'INVALID_ARGS_LAST', + UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT = 'UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT', + MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD', } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts index e3352799ae8..8e05627555e 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts @@ -20,9 +20,12 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST: case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST: case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT: + case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT: throw new UserInputError(error); case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new AuthenticationError(error); + case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: + throw error; default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts index 55f39ddcbb3..6a967761397 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts @@ -19,11 +19,14 @@ export const commonQueryRunnerToRestApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.INVALID_ARGS_FIRST: case CommonQueryRunnerExceptionCode.INVALID_ARGS_LAST: case CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT: + case CommonQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT: throw new BadRequestException(error.message); case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND: throw new NotFoundException('Record not found'); case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new UnauthorizedException(error.message); + case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: + throw error; default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts index 793981e1bca..98bd8880176 100644 --- a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts +++ b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts @@ -1,4 +1,5 @@ import { + type ObjectRecord, type ObjectRecordFilter, type ObjectRecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -8,6 +9,7 @@ import { type CommonSelectedFieldsResult } from 'src/engine/api/common/types/com export enum CommonQueryNames { findOne = 'findOne', findMany = 'findMany', + createMany = 'createMany', } export interface FindOneQueryArgs { @@ -25,4 +27,14 @@ export interface FindManyQueryArgs { after?: string; } -export type CommonQueryArgs = FindOneQueryArgs | FindManyQueryArgs; +export interface CreateManyQueryArgs { + selectedFieldsResult: CommonSelectedFieldsResult; + data: Partial[]; + upsert?: boolean; +} + +export interface CreateOneQueryArgs { + selectedFieldsResult: CommonSelectedFieldsResult; + data: Partial; + upsert?: boolean; +} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts index d9b39abd637..dc947939eb1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts @@ -18,6 +18,7 @@ import { type UpdateOneResolverArgs, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; @@ -39,35 +40,40 @@ export class QueryRunnerArgsFactory { const fieldMetadataMapByNameByName = options.objectMetadataItemWithFieldMaps.fieldsById; + const { objectMetadataItemWithFieldMaps, authContext } = options; + switch (resolverArgsType) { case ResolverArgsType.CreateOne: return { ...args, data: ( - await this.overrideDataByFieldMetadata( - [(args as CreateOneResolverArgs).data], - options, - ) + await this.overrideDataByFieldMetadata({ + partialRecordInputs: [(args as CreateOneResolverArgs).data], + authContext, + objectMetadataItemWithFieldMaps, + }) )[0], } satisfies CreateOneResolverArgs; case ResolverArgsType.CreateMany: return { ...args, - data: await this.overrideDataByFieldMetadata( - (args as CreateManyResolverArgs).data, - options, - ), + data: await this.overrideDataByFieldMetadata({ + partialRecordInputs: (args as CreateManyResolverArgs).data, + authContext, + objectMetadataItemWithFieldMaps, + }), } satisfies CreateManyResolverArgs; case ResolverArgsType.UpdateOne: return { ...args, id: (args as UpdateOneResolverArgs).id, data: ( - await this.overrideDataByFieldMetadata( - [(args as UpdateOneResolverArgs).data], - options, - false, - ) + await this.overrideDataByFieldMetadata({ + partialRecordInputs: [(args as UpdateOneResolverArgs).data], + authContext, + objectMetadataItemWithFieldMaps, + shouldBackfillPositionIfUndefined: false, + }) )[0], } satisfies UpdateOneResolverArgs; case ResolverArgsType.UpdateMany: @@ -78,11 +84,12 @@ export class QueryRunnerArgsFactory { options.objectMetadataItemWithFieldMaps, ), data: ( - await this.overrideDataByFieldMetadata( - [(args as UpdateManyResolverArgs).data], - options, - false, - ) + await this.overrideDataByFieldMetadata({ + partialRecordInputs: [(args as UpdateManyResolverArgs).data], + authContext, + objectMetadataItemWithFieldMaps, + shouldBackfillPositionIfUndefined: false, + }) )[0], } satisfies UpdateManyResolverArgs; case ResolverArgsType.FindOne: @@ -115,11 +122,12 @@ export class QueryRunnerArgsFactory { ), ) ?? [], )) as string[], - data: await this.overrideDataByFieldMetadata( - (args as FindDuplicatesResolverArgs).data, - options, - false, - ), + data: await this.overrideDataByFieldMetadata({ + partialRecordInputs: (args as FindDuplicatesResolverArgs).data, + authContext, + objectMetadataItemWithFieldMaps, + shouldBackfillPositionIfUndefined: false, + }), } satisfies FindDuplicatesResolverArgs; case ResolverArgsType.MergeMany: return { @@ -143,18 +151,24 @@ export class QueryRunnerArgsFactory { } } - private async overrideDataByFieldMetadata( - partialRecordInputs: Partial[] | undefined, - options: WorkspaceQueryRunnerOptions, + async overrideDataByFieldMetadata({ + partialRecordInputs, + authContext, + objectMetadataItemWithFieldMaps, shouldBackfillPositionIfUndefined = true, - ): Promise[]> { + }: { + partialRecordInputs: Partial[] | undefined; + authContext: AuthContext; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + shouldBackfillPositionIfUndefined?: boolean; + }): Promise[]> { if (!isDefined(partialRecordInputs)) { return []; } const allOverriddenRecords: Partial[] = []; - const workspace = options.authContext.workspace; + const workspace = authContext.workspace; assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); @@ -163,9 +177,9 @@ export class QueryRunnerArgsFactory { partialRecordInputs, workspaceId: workspace.id, objectMetadata: { - isCustom: options.objectMetadataItemWithFieldMaps.isCustom, - nameSingular: options.objectMetadataItemWithFieldMaps.nameSingular, - fieldIdByName: options.objectMetadataItemWithFieldMaps.fieldIdByName, + isCustom: objectMetadataItemWithFieldMaps.isCustom, + nameSingular: objectMetadataItemWithFieldMaps.nameSingular, + fieldIdByName: objectMetadataItemWithFieldMaps.fieldIdByName, }, shouldBackfillPositionIfUndefined, }); @@ -175,9 +189,9 @@ export class QueryRunnerArgsFactory { const createArgByArgKey: [string, any][] = await Promise.all( Object.entries(record).map(async ([key, value]) => { const fieldMetadataId = - options.objectMetadataItemWithFieldMaps.fieldIdByName[key]; + objectMetadataItemWithFieldMaps.fieldIdByName[key]; const fieldMetadata = - options.objectMetadataItemWithFieldMaps.fieldsById[fieldMetadataId]; + objectMetadataItemWithFieldMaps.fieldsById[fieldMetadataId]; if (!fieldMetadata) { return [key, value]; @@ -193,8 +207,7 @@ export class QueryRunnerArgsFactory { const transformedRecord = await this.recordInputTransformerService.process({ recordInput: { [key]: value }, - objectMetadataMapItem: - options.objectMetadataItemWithFieldMaps, + objectMetadataMapItem: objectMetadataItemWithFieldMaps, }); return [key, transformedRecord[key]]; 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 a639a8923e6..6644ea8a58f 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,8 @@ import { Injectable } from '@nestjs/common'; +import graphqlFields from 'graphql-fields'; + +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { @@ -8,8 +11,14 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; +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 { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service'; +import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @Injectable() export class CreateManyResolverFactory @@ -19,6 +28,8 @@ export class CreateManyResolverFactory constructor( private readonly graphqlQueryRunnerService: GraphqlQueryCreateManyResolverService, + private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} create( @@ -27,6 +38,53 @@ export class CreateManyResolverFactory const internalContext = context; return async (_source, args, _context, info) => { + const workspaceDataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace({ + workspaceId: internalContext.authContext.workspace?.id as string, + }); + + const featureFlagsMap = workspaceDataSource.featureFlagMap; + + if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) { + const graphqlQueryParser = new GraphqlQueryParser( + internalContext.objectMetadataItemWithFieldMaps, + internalContext.objectMetadataMaps, + ); + + const selectedFieldsResult = graphqlQueryParser.parseSelectedFields( + internalContext.objectMetadataItemWithFieldMaps, + graphqlFields(info), + internalContext.objectMetadataMaps, + ); + + try { + const records = await this.commonCreateManyQueryRunnerService.run({ + args: { ...args, selectedFieldsResult }, + authContext: internalContext.authContext, + objectMetadataMaps: internalContext.objectMetadataMaps, + objectMetadataItemWithFieldMaps: + internalContext.objectMetadataItemWithFieldMaps, + }); + + const typeORMObjectRecordsParser = + new ObjectRecordsToGraphqlConnectionHelper( + internalContext.objectMetadataMaps, + ); + + return records.map((record: ObjectRecord) => + typeORMObjectRecordsParser.processRecord({ + objectRecord: record, + objectName: + internalContext.objectMetadataItemWithFieldMaps.nameSingular, + take: 1, + totalCount: 1, + }), + ); + } catch (error) { + return workspaceQueryRunnerGraphqlApiExceptionHandler(error); + } + } + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, info, 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 cc45240fed3..26eef39ef0d 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,7 @@ import { Injectable } from '@nestjs/common'; +import graphqlFields from 'graphql-fields'; + import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { @@ -8,8 +10,14 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; +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 { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service'; +import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; +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 CreateOneResolverFactory @@ -19,6 +27,8 @@ export class CreateOneResolverFactory constructor( private readonly graphqlQueryRunnerService: GraphqlQueryCreateOneResolverService, + private readonly featureFlagService: FeatureFlagService, + private readonly commonCreateOneQueryRunnerService: CommonCreateOneQueryRunnerService, ) {} create( @@ -27,6 +37,49 @@ export class CreateOneResolverFactory const internalContext = context; return async (_source, args, _context, info) => { + const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_COMMON_API_ENABLED, + internalContext.authContext.workspace?.id as string, + ); + + if (isCommonApiEnabled) { + const graphqlQueryParser = new GraphqlQueryParser( + internalContext.objectMetadataItemWithFieldMaps, + internalContext.objectMetadataMaps, + ); + + const selectedFieldsResult = graphqlQueryParser.parseSelectedFields( + internalContext.objectMetadataItemWithFieldMaps, + graphqlFields(info), + internalContext.objectMetadataMaps, + ); + + try { + const record = await this.commonCreateOneQueryRunnerService.run({ + args: { ...args, selectedFieldsResult }, + authContext: internalContext.authContext, + objectMetadataMaps: internalContext.objectMetadataMaps, + objectMetadataItemWithFieldMaps: + internalContext.objectMetadataItemWithFieldMaps, + }); + + const typeORMObjectRecordsParser = + new ObjectRecordsToGraphqlConnectionHelper( + internalContext.objectMetadataMaps, + ); + + return typeORMObjectRecordsParser.processRecord({ + objectRecord: record, + objectName: + internalContext.objectMetadataItemWithFieldMaps.nameSingular, + take: 1, + totalCount: 1, + }); + } catch (error) { + return workspaceQueryRunnerGraphqlApiExceptionHandler(error); + } + } + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, info, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts index c21e3509e5a..35f7a0599ed 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts @@ -7,14 +7,72 @@ import { import isEmpty from 'lodash.isempty'; import { isDefined } from 'twenty-shared/utils'; +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; +import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util'; +import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; @Injectable() export class RestApiCreateManyHandler extends RestApiBaseHandler { + constructor( + private readonly commonCreateManyQueryRunnerService: CommonCreateManyQueryRunnerService, + ) { + super(); + } + + async commonHandle(request: AuthenticatedRequest) { + try { + const { data, depth, upsert } = this.parseRequestArgs(request); + + const { + authContext, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + } = await this.buildCommonOptions(request); + + const selectedFieldsResult = await this.computeSelectedFields({ + depth, + objectMetadataMapItem: objectMetadataItemWithFieldMaps, + objectMetadataMaps, + authContext, + }); + + const records = await this.commonCreateManyQueryRunnerService.run({ + args: { data, selectedFieldsResult, upsert }, + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }); + + return this.formatRestResponse( + records, + objectMetadataItemWithFieldMaps.namePlural, + ); + } catch (error) { + workspaceQueryRunnerRestApiExceptionHandler(error); + } + } + + private formatRestResponse( + records: ObjectRecord[], + objectNamePlural: string, + ) { + return { data: { [objectNamePlural]: records } }; + } + + private parseRequestArgs(request: AuthenticatedRequest) { + return { + data: request.body, + depth: parseDepthRestRequest(request), + upsert: parseUpsertRestRequest(request), + }; + } + async handle(request: AuthenticatedRequest) { const { objectMetadata, repository, restrictedFields } = await this.getRepositoryAndMetadataOrFail(request); diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts index 29775089a99..b7b6889d8d9 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts @@ -7,14 +7,69 @@ import { import isEmpty from 'lodash.isempty'; import { isDefined } from 'twenty-shared/utils'; +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; +import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util'; +import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; @Injectable() export class RestApiCreateOneHandler extends RestApiBaseHandler { + constructor( + private readonly commonCreateOneQueryRunnerService: CommonCreateOneQueryRunnerService, + ) { + super(); + } + + async commonHandle(request: AuthenticatedRequest) { + try { + const { data, depth, upsert } = this.parseRequestArgs(request); + + const { + authContext, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + } = await this.buildCommonOptions(request); + + const selectedFieldsResult = await this.computeSelectedFields({ + depth, + objectMetadataMapItem: objectMetadataItemWithFieldMaps, + objectMetadataMaps, + authContext, + }); + + const record = await this.commonCreateOneQueryRunnerService.run({ + args: { data, selectedFieldsResult, upsert }, + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }); + + return this.formatRestResponse( + record, + objectMetadataItemWithFieldMaps.nameSingular, + ); + } catch (error) { + workspaceQueryRunnerRestApiExceptionHandler(error); + } + } + + private formatRestResponse(record: ObjectRecord, objectNameSingular: string) { + return { data: { [objectNameSingular]: record } }; + } + + private parseRequestArgs(request: AuthenticatedRequest) { + return { + data: request.body, + depth: parseDepthRestRequest(request), + upsert: parseUpsertRestRequest(request), + }; + } + async handle(request: AuthenticatedRequest) { const { objectMetadata, repository, restrictedFields } = await this.getRepositoryAndMetadataOrFail(request); diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts index 72c9f8bfd79..051caf2b781 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts @@ -85,7 +85,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler { objectMetadataItemWithFieldMaps.nameSingular, ); } catch (error) { - return workspaceQueryRunnerRestApiExceptionHandler(error); + workspaceQueryRunnerRestApiExceptionHandler(error); } } diff --git a/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts b/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts index 355c471fd73..bb7451a024c 100644 --- a/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts +++ b/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts @@ -39,11 +39,23 @@ export class RestApiCoreService { } async createOne(request: AuthenticatedRequest) { - return await this.restApiCreateOneHandler.handle(request); + const isCommonApiEnabled = await this.isCommonApiEnabled(request); + + if (isCommonApiEnabled) { + return await this.restApiCreateOneHandler.commonHandle(request); + } else { + return await this.restApiCreateOneHandler.handle(request); + } } async createMany(request: AuthenticatedRequest) { - return await this.restApiCreateManyHandler.handle(request); + const isCommonApiEnabled = await this.isCommonApiEnabled(request); + + if (isCommonApiEnabled) { + return await this.restApiCreateManyHandler.commonHandle(request); + } else { + return await this.restApiCreateManyHandler.handle(request); + } } async findDuplicates(request: AuthenticatedRequest) { diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/upsert-parser-utils/__tests__/parse-upsert-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/upsert-parser-utils/__tests__/parse-upsert-rest-request.util.spec.ts new file mode 100644 index 00000000000..a39e3d1eeec --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/upsert-parser-utils/__tests__/parse-upsert-rest-request.util.spec.ts @@ -0,0 +1,61 @@ +import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util'; + +describe('parseUpsertRestRequest', () => { + it('should return false when upsert query parameter is not defined', () => { + const request = { + query: {}, + } as any; + + const result = parseUpsertRestRequest(request); + + expect(result).toBe(false); + }); + + it('should return true when upsert query parameter is "true"', () => { + const request = { + query: { + upsert: 'true', + }, + } as any; + + const result = parseUpsertRestRequest(request); + + expect(result).toBe(true); + }); + + it('should return false when upsert query parameter is "false"', () => { + const request = { + query: { + upsert: 'false', + }, + } as any; + + const result = parseUpsertRestRequest(request); + + expect(result).toBe(false); + }); + + it('should return false when upsert query parameter is empty string', () => { + const request = { + query: { + upsert: '', + }, + } as any; + + const result = parseUpsertRestRequest(request); + + expect(result).toBe(false); + }); + + it('should return false when upsert query parameter is a boolean true', () => { + const request = { + query: { + upsert: true, + }, + } as any; + + const result = parseUpsertRestRequest(request); + + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util.ts new file mode 100644 index 00000000000..5eeeb50a656 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/upsert-parser-utils/parse-upsert-rest-request.util.ts @@ -0,0 +1,13 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; + +export const parseUpsertRestRequest = ( + request: AuthenticatedRequest, +): boolean => { + if (!isDefined(request.query.upsert)) { + return false; + } + + return request.query.upsert === 'true'; +}; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/parameters.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/parameters.utils.spec.ts index 81fb0344ea2..2ac46ff91e9 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/parameters.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/parameters.utils.spec.ts @@ -62,8 +62,7 @@ describe('computeParameters', () => { in: 'query', description: `Determines the level of nested related objects to include in the response. - 0: Primary object only - - 1: Primary object + direct relations - - 2: Primary object + direct relations + nested relations`, + - 1: Primary object + direct relations`, required: false, schema: { type: 'integer', diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index a5828ba2245..95fc301213c 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -14,6 +14,7 @@ import { computeLimitParameters, computeOrderByParameters, computeStartingAfterParameters, + computeUpsertParameters, } from 'src/engine/core-modules/open-api/utils/parameters.utils'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util'; @@ -208,6 +209,7 @@ export const computeParameterComponents = ( endingBefore: computeEndingBeforeParameters(), filter: computeFilterParameters(), depth: computeDepthParameters(), + upsert: computeUpsertParameters(), orderBy: computeOrderByParameters(), limit: computeLimitParameters(fromMetadata), }; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/parameters.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/parameters.utils.ts index 4a264fa38f2..b8e06203c3d 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/parameters.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/parameters.utils.ts @@ -51,8 +51,7 @@ export const computeDepthParameters = (): OpenAPIV3_1.ParameterObject => { in: 'query', description: `Determines the level of nested related objects to include in the response. - 0: Primary object only - - 1: Primary object + direct relations - - 2: Primary object + direct relations + nested relations`, + - 1: Primary object + direct relations`, required: false, schema: { type: 'integer', @@ -62,6 +61,20 @@ export const computeDepthParameters = (): OpenAPIV3_1.ParameterObject => { }; }; +export const computeUpsertParameters = (): OpenAPIV3_1.ParameterObject => { + return { + name: 'upsert', + in: 'query', + description: + 'If true, creates the object or updates it if it already exists.', + required: false, + schema: { + type: 'boolean', + default: false, + }, + }; +}; + export const computeFilterParameters = (): OpenAPIV3_1.ParameterObject => { return { name: 'filter', diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/path.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/path.utils.ts index 6ca7c7589df..88d024daf19 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/path.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/path.utils.ts @@ -27,7 +27,10 @@ export const computeBatchPath = ( tags: [item.namePlural], summary: `Create Many ${item.namePlural}`, operationId: `createMany${capitalize(item.namePlural)}`, - parameters: [{ $ref: '#/components/parameters/depth' }], + parameters: [ + { $ref: '#/components/parameters/depth' }, + { $ref: '#/components/parameters/upsert' }, + ], requestBody: getArrayRequestBody(capitalize(item.nameSingular)), responses: { '201': getCreateManyResponse201(item), @@ -65,7 +68,10 @@ export const computeManyResultPath = ( tags: [item.namePlural], summary: `Create One ${item.nameSingular}`, operationId: `createOne${capitalize(item.nameSingular)}`, - parameters: [{ $ref: '#/components/parameters/depth' }], + parameters: [ + { $ref: '#/components/parameters/depth' }, + { $ref: '#/components/parameters/upsert' }, + ], requestBody: getRequestBody(capitalize(item.nameSingular)), responses: { '201': getCreateOneResponse201(item),