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 index b5b6955799..23fa88084b 100644 --- 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 @@ -36,6 +36,7 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource'; @@ -74,14 +75,24 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, workspaceDataSource, } = queryRunnerContext; + if (!isDefined(flatIndexMaps)) { + throw new CommonQueryRunnerException( + `Missing flatIndexMaps in queryRunnerContext`, + CommonQueryRunnerExceptionCode.MISSING_FLAT_INDEX_MAPS, + { userFriendlyMessage: STANDARD_ERROR_MESSAGE }, + ); + } + const objectRecords = await this.insertOrUpsertRecords({ repository, flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, args, workspaceId: authContext.workspace.id, }); @@ -193,6 +204,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, args, workspaceId, }: { @@ -200,6 +212,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata: FlatObjectMetadata; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; + flatIndexMaps: FlatEntityMaps; args: CommonExtendedInput; workspaceId: string; }): Promise { @@ -222,6 +235,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, args, selectedFieldsResult, workspaceId, @@ -233,6 +247,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, args, selectedFieldsResult, workspaceId, @@ -241,6 +256,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer flatObjectMetadata: FlatObjectMetadata; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; + flatIndexMaps: FlatEntityMaps; args: CreateManyQueryArgs; selectedFieldsResult: CommonSelectedFieldsResult; workspaceId: string; @@ -248,6 +264,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer const conflictingFieldGroups = getConflictingFields( flatObjectMetadata, flatFieldMetadataMaps, + flatIndexMaps, ); const existingRecords = await this.findExistingRecords({ repository, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts index 43263204f7..fb2d1741f5 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type.ts @@ -4,6 +4,6 @@ export type ConflictingProperty = { }; export type ConflictingFieldGroup = { - baseField: string; + baseFields: string[]; conflictingProperties: ConflictingProperty[]; }; 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 index 058c569c26..814cdc3eb4 100644 --- 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 @@ -31,7 +31,7 @@ describe('buildWhereConditions', () => { it('builds a single where condition for a flat field using all defined values', () => { const groups: ConflictingFieldGroup[] = [ { - baseField: 'uniqueText', + baseFields: ['uniqueText'], conflictingProperties: [ { fullPath: 'uniqueText', column: 'uniqueText' }, ], @@ -54,7 +54,7 @@ describe('buildWhereConditions', () => { it('skips adding a condition when all values for a field are undefined', () => { const groups: ConflictingFieldGroup[] = [ { - baseField: 'uniqueText', + baseFields: ['uniqueText'], conflictingProperties: [ { fullPath: 'uniqueText', column: 'uniqueText' }, ], @@ -69,7 +69,7 @@ describe('buildWhereConditions', () => { it('builds conditions for nested paths', () => { const groups: ConflictingFieldGroup[] = [ { - baseField: 'emailsField', + baseFields: ['emailsField'], conflictingProperties: [ { fullPath: 'emailsField.primaryEmail', @@ -95,13 +95,13 @@ describe('buildWhereConditions', () => { it('builds multiple conditions when multiple conflicting fields are provided', () => { const groups: ConflictingFieldGroup[] = [ { - baseField: 'uniqueText', + baseFields: ['uniqueText'], conflictingProperties: [ { fullPath: 'uniqueText', column: 'uniqueText' }, ], }, { - baseField: 'emailsField', + baseFields: ['emailsField'], conflictingProperties: [ { fullPath: 'emailsField.primaryEmail', 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 index c266e7caa8..c04a7273d2 100644 --- 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 @@ -7,15 +7,15 @@ import { categorizeRecords } from 'src/engine/api/common/common-query-runners/co describe('categorizeRecords', () => { const conflictingFieldGroups: ConflictingFieldGroup[] = [ { - baseField: 'id', + baseFields: ['id'], conflictingProperties: [{ fullPath: 'id', column: 'id' }], }, { - baseField: 'uniqueText', + baseFields: ['uniqueText'], conflictingProperties: [{ fullPath: 'uniqueText', column: 'uniqueText' }], }, { - baseField: 'emailsField', + baseFields: ['emailsField'], conflictingProperties: [ { fullPath: 'emailsField.primaryEmail', 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 index 90bcea4f10..d204cdaba2 100644 --- 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 @@ -1,8 +1,9 @@ -import { FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { getConflictingFields } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/get-conflicting-fields.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; describe('getConflictingFields', () => { @@ -33,50 +34,93 @@ describe('getConflictingFields', () => { ...overrides, }) as FlatFieldMetadata; + type MockIndexField = { + fieldMetadataId: string; + subFieldName?: string | null; + order?: number; + }; + + const createMockIndex = ({ + id, + name, + isUnique, + fields, + }: { + id: string; + name: string; + isUnique: boolean; + fields: MockIndexField[]; + }): FlatIndexMetadata => + ({ + id, + universalIdentifier: id, + name, + objectMetadataId, + workspaceId, + isUnique, + indexWhereClause: null, + isCustom: false, + applicationId: null, + flatIndexFieldMetadatas: fields.map((field, index) => ({ + id: `${id}-field-${index}`, + universalIdentifier: `${id}-field-${index}`, + indexMetadataId: id, + fieldMetadataId: field.fieldMetadataId, + subFieldName: field.subFieldName ?? null, + order: field.order ?? index, + })), + }) as unknown as FlatIndexMetadata; + const idField = createMockField({ id: 'id-field-id', name: 'id', type: FieldMetadataType.UUID, - isUnique: true, }); const uniqueTextField = createMockField({ id: 'unique-text-id', name: 'uniqueText', type: FieldMetadataType.TEXT, - isUnique: true, }); - const emailsUniqueField = createMockField({ - id: 'emails-unique-id', + const otherTextField = createMockField({ + id: 'other-text-id', + name: 'otherText', + type: FieldMetadataType.TEXT, + }); + + const emailsField = createMockField({ + id: 'emails-id', name: 'emailsField', type: FieldMetadataType.EMAILS, - isUnique: true, }); - const phonesUniqueField = createMockField({ - id: 'phones-unique-id', + const phonesField = createMockField({ + id: 'phones-id', name: 'phonesField', type: FieldMetadataType.PHONES, - isUnique: true, }); - const phonesNotUniqueField = createMockField({ - id: 'phones-not-unique-id', - name: 'phonesField', - type: FieldMetadataType.PHONES, - isUnique: false, - }); - - const addressUniqueFieldNoIncludedProp = createMockField({ - id: 'address-unique-id', + const addressField = createMockField({ + id: 'address-id', name: 'addressField', type: FieldMetadataType.ADDRESS, - isUnique: true, + }); + + const companyRelationField = createMockField({ + id: 'company-relation-id', + name: 'company', + type: FieldMetadataType.RELATION, + settings: { relationType: RelationType.MANY_TO_ONE }, + } as Partial & { + id: string; + name: string; + type: FieldMetadataType; }); const buildFlatObjectMetadata = ( fields: FlatFieldMetadata[], + indexMetadataIds: string[] = [], ): FlatObjectMetadata => ({ id: objectMetadataId, @@ -91,8 +135,8 @@ describe('getConflictingFields', () => { createdAt: new Date(), updatedAt: new Date(), universalIdentifier: objectMetadataId, - fieldIds: fields.map((f) => f.id), - indexMetadataIds: [], + fieldIds: fields.map((field) => field.id), + indexMetadataIds, viewIds: [], applicationId: null, }) as unknown as FlatObjectMetadata; @@ -119,24 +163,54 @@ describe('getConflictingFields', () => { universalIdentifiersByApplicationId: {}, }); - it('returns id and unique non-composite fields as conflicts', () => { + const buildFlatIndexMaps = ( + indexes: FlatIndexMetadata[], + ): FlatEntityMaps => ({ + byUniversalIdentifier: indexes.reduce( + (acc, index) => { + acc[index.universalIdentifier] = index; + + return acc; + }, + {} as Record, + ), + universalIdentifierById: indexes.reduce( + (acc, index) => { + acc[index.id] = index.universalIdentifier; + + return acc; + }, + {} as Record, + ), + universalIdentifiersByApplicationId: {}, + }); + + it('returns id and single-field unique index conflicts', () => { const fields = [idField, uniqueTextField]; - const flatObjectMetadata = buildFlatObjectMetadata(fields); + const index = createMockIndex({ + id: 'unique-text-index', + name: 'uniqueTextUniqueIndex', + isUnique: true, + fields: [{ fieldMetadataId: uniqueTextField.id }], + }); + const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]); const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + const flatIndexMaps = buildFlatIndexMaps([index]); const result = getConflictingFields( flatObjectMetadata, flatFieldMetadataMaps, + flatIndexMaps, ); expect(result).toEqual( expect.arrayContaining([ { - baseField: 'id', + baseFields: ['id'], conflictingProperties: [{ fullPath: 'id', column: 'id' }], }, { - baseField: 'uniqueText', + baseFields: ['uniqueText'], conflictingProperties: [ { fullPath: 'uniqueText', column: 'uniqueText' }, ], @@ -146,23 +220,31 @@ describe('getConflictingFields', () => { }); it('returns composite field with included unique property using full path and computed column', () => { - const fields = [idField, emailsUniqueField]; - const flatObjectMetadata = buildFlatObjectMetadata(fields); + const fields = [idField, emailsField]; + const index = createMockIndex({ + id: 'emails-index', + name: 'emailsUniqueIndex', + isUnique: true, + fields: [{ fieldMetadataId: emailsField.id }], + }); + const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]); const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + const flatIndexMaps = buildFlatIndexMaps([index]); const result = getConflictingFields( flatObjectMetadata, flatFieldMetadataMaps, + flatIndexMaps, ); expect(result).toEqual( expect.arrayContaining([ { - baseField: 'id', + baseFields: ['id'], conflictingProperties: [{ fullPath: 'id', column: 'id' }], }, { - baseField: 'emailsField', + baseFields: ['emailsField'], conflictingProperties: [ { fullPath: 'emailsField.primaryEmail', @@ -175,22 +257,30 @@ describe('getConflictingFields', () => { }); it('returns every included unique property for phone composite fields', () => { - const fields = [idField, phonesUniqueField]; - const flatObjectMetadata = buildFlatObjectMetadata(fields); + const fields = [idField, phonesField]; + const index = createMockIndex({ + id: 'phones-index', + name: 'phonesUniqueIndex', + isUnique: true, + fields: [{ fieldMetadataId: phonesField.id }], + }); + const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]); const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + const flatIndexMaps = buildFlatIndexMaps([index]); const result = getConflictingFields( flatObjectMetadata, flatFieldMetadataMaps, + flatIndexMaps, ); expect(result).toEqual([ { - baseField: 'id', + baseFields: ['id'], conflictingProperties: [{ fullPath: 'id', column: 'id' }], }, { - baseField: 'phonesField', + baseFields: ['phonesField'], conflictingProperties: [ { fullPath: 'phonesField.primaryPhoneNumber', @@ -209,39 +299,123 @@ describe('getConflictingFields', () => { ]); }); - it('does not include composite fields without included unique property', () => { - const fields = [idField, addressUniqueFieldNoIncludedProp]; - const flatObjectMetadata = buildFlatObjectMetadata(fields); + it('skips composite unique index without included unique property', () => { + const fields = [idField, addressField]; + const index = createMockIndex({ + id: 'address-index', + name: 'addressUniqueIndex', + isUnique: true, + fields: [{ fieldMetadataId: addressField.id }], + }); + const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]); const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + const flatIndexMaps = buildFlatIndexMaps([index]); const result = getConflictingFields( flatObjectMetadata, flatFieldMetadataMaps, + flatIndexMaps, ); expect(result).toEqual([ { - baseField: 'id', + baseFields: ['id'], conflictingProperties: [{ fullPath: 'id', column: 'id' }], }, ]); }); - it('ignores non-unique fields', () => { - const fields = [idField, phonesNotUniqueField]; - const flatObjectMetadata = buildFlatObjectMetadata(fields); + it('ignores non-unique indexes', () => { + const fields = [idField, uniqueTextField]; + const index = createMockIndex({ + id: 'non-unique-index', + name: 'nonUniqueIndex', + isUnique: false, + fields: [{ fieldMetadataId: uniqueTextField.id }], + }); + const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]); const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + const flatIndexMaps = buildFlatIndexMaps([index]); const result = getConflictingFields( flatObjectMetadata, flatFieldMetadataMaps, + flatIndexMaps, ); expect(result).toEqual([ { - baseField: 'id', + baseFields: ['id'], conflictingProperties: [{ fullPath: 'id', column: 'id' }], }, ]); }); + + it('returns a single group with every column of a composite (multi-field) unique index, ordered', () => { + const fields = [idField, uniqueTextField, otherTextField]; + const index = createMockIndex({ + id: 'composite-index', + name: 'uniqueTextOtherTextUniqueIndex', + isUnique: true, + fields: [ + { fieldMetadataId: otherTextField.id, order: 1 }, + { fieldMetadataId: uniqueTextField.id, order: 0 }, + ], + }); + const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + const flatIndexMaps = buildFlatIndexMaps([index]); + + const result = getConflictingFields( + flatObjectMetadata, + flatFieldMetadataMaps, + flatIndexMaps, + ); + + expect(result).toEqual( + expect.arrayContaining([ + { + baseFields: ['uniqueText', 'otherText'], + conflictingProperties: [ + { fullPath: 'uniqueText', column: 'uniqueText' }, + { fullPath: 'otherText', column: 'otherText' }, + ], + }, + ]), + ); + }); + + it('resolves relation fields to their join column in a composite unique index', () => { + const fields = [idField, companyRelationField, uniqueTextField]; + const index = createMockIndex({ + id: 'company-text-index', + name: 'companyIdUniqueTextUniqueIndex', + isUnique: true, + fields: [ + { fieldMetadataId: companyRelationField.id, order: 0 }, + { fieldMetadataId: uniqueTextField.id, order: 1 }, + ], + }); + const flatObjectMetadata = buildFlatObjectMetadata(fields, [index.id]); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fields); + const flatIndexMaps = buildFlatIndexMaps([index]); + + const result = getConflictingFields( + flatObjectMetadata, + flatFieldMetadataMaps, + flatIndexMaps, + ); + + expect(result).toEqual( + expect.arrayContaining([ + { + baseFields: ['company', 'uniqueText'], + conflictingProperties: [ + { fullPath: 'companyId', column: 'companyId' }, + { fullPath: 'uniqueText', column: 'uniqueText' }, + ], + }, + ]), + ); + }); }); 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 index 9ee5687ad7..9a632c166b 100644 --- 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 @@ -32,7 +32,7 @@ describe('getMatchingRecordId', () => { const conflictingFieldGroups: ConflictingFieldGroup[] = [ { - baseField: 'emailsField', + baseFields: ['emailsField'], conflictingProperties: [ { fullPath: 'emailsField.primaryEmail', @@ -61,7 +61,7 @@ describe('getMatchingRecordId', () => { const conflictingFieldGroups: ConflictingFieldGroup[] = [ { - baseField: 'phonesField', + baseFields: ['phonesField'], conflictingProperties: [ { fullPath: 'phonesField.primaryPhoneNumber', @@ -94,7 +94,7 @@ describe('getMatchingRecordId', () => { const conflictingFieldGroups: ConflictingFieldGroup[] = [ { - baseField: 'phonesField', + baseFields: ['phonesField'], conflictingProperties: [ { fullPath: 'phonesField.primaryPhoneNumber', @@ -124,7 +124,7 @@ describe('getMatchingRecordId', () => { const conflictingFieldGroups: ConflictingFieldGroup[] = [ { - baseField: 'emailsField', + baseFields: ['emailsField'], conflictingProperties: [ { fullPath: 'emailsField.primaryEmail', @@ -151,11 +151,11 @@ describe('getMatchingRecordId', () => { const conflictingFieldGroups: ConflictingFieldGroup[] = [ { - baseField: 'id', + baseFields: ['id'], conflictingProperties: [{ fullPath: 'id', column: 'id' }], }, { - baseField: 'uniqueText', + baseFields: ['uniqueText'], conflictingProperties: [ { fullPath: 'uniqueText', column: 'uniqueText' }, ], @@ -179,13 +179,13 @@ describe('getMatchingRecordId', () => { const conflictingFieldGroups: ConflictingFieldGroup[] = [ { - baseField: 'uniqueText', + baseFields: ['uniqueText'], conflictingProperties: [ { fullPath: 'uniqueText', column: 'uniqueText' }, ], }, { - baseField: 'emailsField', + baseFields: ['emailsField'], conflictingProperties: [ { fullPath: 'emailsField.primaryEmail', 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 index 9d4d9f7005..16fd53e21f 100644 --- 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 @@ -1,39 +1,172 @@ -import { compositeTypeDefinitions } from 'twenty-shared/types'; -import { capitalize } from 'twenty-shared/utils'; +import { compositeTypeDefinitions, RelationType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; -import { type ConflictingFieldGroup } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; +import { + type ConflictingFieldGroup, + type ConflictingProperty, +} from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/conflicting-field-group.type'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; +import { + type FlatIndexFieldMetadata, + type FlatIndexMetadata, +} from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +const computeConflictingPropertiesForIndexField = ({ + flatFieldMetadata, + subFieldName, +}: { + flatFieldMetadata: FlatFieldMetadata; + subFieldName: string | null; +}): ConflictingProperty[] | undefined => { + if (isMorphOrRelationFlatFieldMetadata(flatFieldMetadata)) { + if (flatFieldMetadata.settings?.relationType !== RelationType.MANY_TO_ONE) { + return undefined; + } + + const joinColumn = computeMorphOrRelationFieldJoinColumnName({ + name: flatFieldMetadata.name, + }); + + return [{ fullPath: joinColumn, column: joinColumn }]; + } + + if (isCompositeFieldMetadataType(flatFieldMetadata.type)) { + const compositeType = compositeTypeDefinitions.get(flatFieldMetadata.type); + + if (!isDefined(compositeType)) { + return undefined; + } + + if (isDefined(subFieldName)) { + const property = compositeType.properties.find( + (compositeProperty) => compositeProperty.name === subFieldName, + ); + + if (!isDefined(property)) { + return undefined; + } + + return [ + { + fullPath: `${flatFieldMetadata.name}.${property.name}`, + column: computeCompositeColumnName( + { name: flatFieldMetadata.name, type: flatFieldMetadata.type }, + property, + ), + }, + ]; + } + + return compositeType.properties + .filter((property) => property.isIncludedInUniqueConstraint) + .map((property) => ({ + fullPath: `${flatFieldMetadata.name}.${property.name}`, + column: computeCompositeColumnName( + { name: flatFieldMetadata.name, type: flatFieldMetadata.type }, + property, + ), + })); + } + + return [{ fullPath: flatFieldMetadata.name, column: flatFieldMetadata.name }]; +}; + +const computeConflictingPropertiesForIndex = ({ + flatIndexFieldMetadatas, + flatFieldMetadataMaps, +}: { + flatIndexFieldMetadatas: FlatIndexFieldMetadata[]; + flatFieldMetadataMaps: FlatEntityMaps; +}): + | { baseFields: string[]; conflictingProperties: ConflictingProperty[] } + | undefined => { + const orderedIndexFields = [...flatIndexFieldMetadatas].sort( + (a, b) => a.order - b.order, + ); + + const baseFields: string[] = []; + const conflictingProperties: ConflictingProperty[] = []; + + for (const indexField of orderedIndexFields) { + const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: indexField.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(flatFieldMetadata)) { + return undefined; + } + + const propertiesForField = computeConflictingPropertiesForIndexField({ + flatFieldMetadata, + subFieldName: indexField.subFieldName, + }); + + if (!isDefined(propertiesForField) || propertiesForField.length === 0) { + return undefined; + } + + if (!baseFields.includes(flatFieldMetadata.name)) { + baseFields.push(flatFieldMetadata.name); + } + + conflictingProperties.push(...propertiesForField); + } + + return { baseFields, conflictingProperties }; +}; + export const getConflictingFields = ( flatObjectMetadata: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, + flatIndexMaps: FlatEntityMaps, ): ConflictingFieldGroup[] => { - return getFlatFieldsFromFlatObjectMetadata( + const conflictingFieldGroups: ConflictingFieldGroup[] = []; + + const idField = getFlatFieldsFromFlatObjectMetadata( flatObjectMetadata, flatFieldMetadataMaps, - ) - .filter((field) => field.isUnique || field.name === 'id') - .map((field) => { - const compositeType = compositeTypeDefinitions.get(field.type); + ).find((field) => field.name === 'id'); - if (!compositeType) { - return { - baseField: field.name, - conflictingProperties: [{ fullPath: field.name, column: field.name }], - }; - } + if (isDefined(idField)) { + conflictingFieldGroups.push({ + baseFields: ['id'], + conflictingProperties: [{ fullPath: 'id', column: 'id' }], + }); + } - const conflictingProperties = compositeType.properties - .filter((prop) => prop.isIncludedInUniqueConstraint) - .map((property) => ({ - fullPath: `${field.name}.${property.name}`, - column: `${field.name}${capitalize(property.name)}`, - })); + const uniqueIndexes = findManyFlatEntityByIdInFlatEntityMaps({ + flatEntityIds: flatObjectMetadata.indexMetadataIds, + flatEntityMaps: flatIndexMaps, + }).filter((flatIndexMetadata) => flatIndexMetadata.isUnique); - return { baseField: field.name, conflictingProperties }; - }) - .filter((group) => group.conflictingProperties.length > 0); + for (const flatIndexMetadata of uniqueIndexes) { + const indexConflictingFields = computeConflictingPropertiesForIndex({ + flatIndexFieldMetadatas: flatIndexMetadata.flatIndexFieldMetadatas, + flatFieldMetadataMaps, + }); + + if ( + !isDefined(indexConflictingFields) || + indexConflictingFields.conflictingProperties.length === 0 + ) { + continue; + } + + conflictingFieldGroups.push({ + baseFields: indexConflictingFields.baseFields, + conflictingProperties: indexConflictingFields.conflictingProperties, + }); + } + + return conflictingFieldGroups; }; 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 index 2f587af82d..e4136d9adb 100644 --- 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 @@ -50,16 +50,28 @@ export const getMatchingRecordId = ( if ([...new Set(matchingRecordIds)].length > 1) { const conflictingFieldsValues = conflictingFieldGroups - .flatMap((group) => group.conflictingProperties) - .map((conflictingProperty) => { - const value = getValueFromPath(record, conflictingProperty.fullPath); + .map((group) => { + const values = group.conflictingProperties + .map((conflictingProperty) => { + const value = getValueFromPath( + record, + conflictingProperty.fullPath, + ); - return isDefined(value) - ? `${conflictingProperty.fullPath}: ${value}` - : undefined; + return isDefined(value) + ? `${conflictingProperty.fullPath}: ${value}` + : undefined; + }) + .filter(isDefined); + + if (values.length === 0) { + return undefined; + } + + return `${group.baseFields.join(', ')} (${values.join(', ')})`; }) .filter(isDefined) - .join(', '); + .join('; '); throw new CommonQueryRunnerException( `Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`, 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 402d0a618f..6bba0374c0 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 @@ -3,6 +3,7 @@ import { type MessageDescriptor } from '@lingui/core'; import { CustomException } from 'src/utils/custom-exception'; export enum CommonQueryRunnerExceptionCode { + MISSING_FLAT_INDEX_MAPS = 'MISSING_FLAT_INDEX_MAPS', RECORD_NOT_FOUND = 'RECORD_NOT_FOUND', INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT', INVALID_AUTH_CONTEXT = 'INVALID_AUTH_CONTEXT', 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 6016cca0da..dbbb21a7e9 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 @@ -33,6 +33,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = ( throw new UserInputError(error); case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new AuthenticationError(error); + case CommonQueryRunnerExceptionCode.MISSING_FLAT_INDEX_MAPS: case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR: throw new InternalServerError(error); 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 e9a0ffc760..253614b4ef 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 @@ -34,6 +34,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = ( throw new NotFoundException('Record not found'); case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new UnauthorizedException(error.message); + case CommonQueryRunnerExceptionCode.MISSING_FLAT_INDEX_MAPS: case CommonQueryRunnerExceptionCode.MISSING_SYSTEM_FIELD: case CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR: throw new InternalServerErrorException(error.message); diff --git a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts index 6d0c749731..38ded1050c 100644 --- a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts +++ b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts @@ -18,6 +18,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; @@ -44,6 +45,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache RecordTransformerModule, FeatureFlagModule, WorkspaceCacheModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, ], providers: [ ProcessNestedRelationsHelper, diff --git a/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts b/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts index 2c32d0e079..938ffac8f6 100644 --- a/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts +++ b/packages/twenty-server/src/engine/api/common/types/common-base-query-runner-context.type.ts @@ -1,6 +1,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; export type CommonBaseQueryRunnerContext = { @@ -8,5 +9,6 @@ export type CommonBaseQueryRunnerContext = { flatObjectMetadata: FlatObjectMetadata; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; + flatIndexMaps?: FlatEntityMaps; objectIdByNameSingular: Record; }; diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts index a591f72974..42e9e7b00a 100644 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/direct-execution.service.ts @@ -206,10 +206,12 @@ export class DirectExecutionService { graphQLResolverNameMap, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ 'graphQLResolverNameMap', 'flatObjectMetadataMaps', 'flatFieldMetadataMaps', + 'flatIndexMaps', ]); const { idByNameSingular: objectIdByNameSingular } = @@ -235,6 +237,7 @@ export class DirectExecutionService { entry, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, ); diff --git a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts index 6b66b7c287..6d5332c0eb 100644 --- a/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util.ts @@ -2,12 +2,14 @@ import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-executi import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; export const buildWorkspaceSchemaBuilderContext = ( entry: ResolverNameMapEntry, flatObjectMetadataMaps: FlatEntityMaps, flatFieldMetadataMaps: FlatEntityMaps, + flatIndexMaps: FlatEntityMaps, objectIdByNameSingular: Record, ): WorkspaceSchemaBuilderContext => { const flatObjectMetadata = @@ -25,6 +27,7 @@ export const buildWorkspaceSchemaBuilderContext = ( flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, }; }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts index 625eeaaa64..cd5d0b5547 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-graphql-schema-sdl/workspace-graphql-schema-sdl.service.ts @@ -25,6 +25,7 @@ export type WorkspaceGraphqlSchemaSDLResult = { usedScalarNames: string[]; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; + flatIndexMaps: FlatEntityMaps; }; @Injectable() @@ -168,6 +169,7 @@ export class WorkspaceGraphqlSchemaSDLService { usedScalarNames, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, }; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts index 2d622082a1..703ff4ce6d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts @@ -14,6 +14,7 @@ import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-reso import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { getResolverName } from 'src/engine/utils/get-resolver-name.util'; @@ -56,6 +57,7 @@ export class WorkspaceResolverFactory { async create( flatObjectMetadataMaps: FlatEntityMaps, flatFieldMetadataMaps: FlatEntityMaps, + flatIndexMaps: FlatEntityMaps, objectIdByNameSingular: Record, workspaceResolverBuilderMethods: WorkspaceResolverBuilderMethods, ): Promise { @@ -113,6 +115,7 @@ export class WorkspaceResolverFactory { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, }); } @@ -144,6 +147,7 @@ export class WorkspaceResolverFactory { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts index 10f8aa2cf2..a9e79ee1df 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts @@ -1,10 +1,12 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; export interface WorkspaceSchemaBuilderContext { flatObjectMetadata: FlatObjectMetadata; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; + flatIndexMaps: FlatEntityMaps; objectIdByNameSingular: Record; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 540fa1c7d2..2934e220ef 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -39,6 +39,7 @@ export class WorkspaceSchemaFactory { usedScalarNames, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, } = schemaSDLResult; const { idByNameSingular } = buildObjectIdByNameMaps( @@ -48,6 +49,7 @@ export class WorkspaceSchemaFactory { const autoGeneratedResolvers = await this.workspaceResolverFactory.create( flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, idByNameSingular, workspaceResolverBuilderMethodNames, ); diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts index 3d81553d2c..5bdb302632 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-base.handler.ts @@ -159,6 +159,7 @@ export abstract class RestApiBaseHandler { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, } = await this.getObjectMetadata(request, parsedObject); @@ -169,6 +170,7 @@ export abstract class RestApiBaseHandler { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, }; } 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 c07305b3c6..ecb10569f3 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 @@ -26,6 +26,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, } = await this.buildCommonOptions(request); @@ -45,6 +46,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, }, ); 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 8888e9f61d..5b98e51d28 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 @@ -27,6 +27,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, } = await this.buildCommonOptions(request); @@ -46,6 +47,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular, }, ); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts index 7c1fb819e6..7b4c623df4 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/common-api-context-builder.service.ts @@ -51,11 +51,15 @@ export class CommonApiContextBuilderService { }): Promise { const workspaceId = authContext.workspace.id; - const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatIndexMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, - flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatIndexMaps', + ], }, ); @@ -107,6 +111,7 @@ export class CommonApiContextBuilderService { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatIndexMaps, objectIdByNameSingular: idByNameSingular, }, selectedFields, diff --git a/packages/twenty-server/test/integration/graphql/suites/upsert/composite-unique-index-upsert.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/upsert/composite-unique-index-upsert.integration-spec.ts new file mode 100644 index 0000000000..b4c284aa92 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/upsert/composite-unique-index-upsert.integration-spec.ts @@ -0,0 +1,393 @@ +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { type Manifest } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +// A composite UNIQUE index can't be created through the public index API +// (createOneIndex forces isUnique: false). Application manifest sync is the only +// API-driven path that yields one: it runs the same validate/build/run migration +// internally, provisioning a real composite unique index we can upsert against. +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const FIRST_FIELD_ID = uuidv4(); +const SECOND_FIELD_ID = uuidv4(); +const PAYLOAD_FIELD_ID = uuidv4(); +const INDEX_ID = uuidv4(); + +const DUAL_A_FIRST_FIELD_ID = uuidv4(); +const DUAL_A_SECOND_FIELD_ID = uuidv4(); +const DUAL_B_FIRST_FIELD_ID = uuidv4(); +const DUAL_B_SECOND_FIELD_ID = uuidv4(); +const DUAL_PAYLOAD_FIELD_ID = uuidv4(); +const DUAL_INDEX_A_ID = uuidv4(); +const DUAL_INDEX_B_ID = uuidv4(); + +const OBJECT = buildDefaultObjectManifest({ + applicationUniversalIdentifier: TEST_APP_ID, + nameSingular: 'compositeUpsertRecord', + namePlural: 'compositeUpsertRecords', + labelSingular: 'Composite Upsert Record', + labelPlural: 'Composite Upsert Records', + description: 'Object to test composite unique index upsert', + icon: 'IconTestRecord', + additionalFields: [ + { + universalIdentifier: FIRST_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'firstKey', + label: 'First Key', + isNullable: true, + }, + { + universalIdentifier: SECOND_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'secondKey', + label: 'Second Key', + isNullable: true, + }, + { + universalIdentifier: PAYLOAD_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'payload', + label: 'Payload', + isNullable: true, + }, + ], +}); + +// Second object with TWO independent composite unique indexes +// (indexA on aFirst+aSecond, indexB on bFirst+bSecond). This lets us exercise +// upsert conflict resolution when a payload can match different rows through +// different unique indexes. +const DUAL_OBJECT = buildDefaultObjectManifest({ + applicationUniversalIdentifier: TEST_APP_ID, + nameSingular: 'dualCompositeUpsertRecord', + namePlural: 'dualCompositeUpsertRecords', + labelSingular: 'Dual Composite Upsert Record', + labelPlural: 'Dual Composite Upsert Records', + description: 'Object to test upsert with two composite unique indexes', + icon: 'IconTestRecord', + additionalFields: [ + { + universalIdentifier: DUAL_A_FIRST_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'aFirstKey', + label: 'A First Key', + isNullable: true, + }, + { + universalIdentifier: DUAL_A_SECOND_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'aSecondKey', + label: 'A Second Key', + isNullable: true, + }, + { + universalIdentifier: DUAL_B_FIRST_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'bFirstKey', + label: 'B First Key', + isNullable: true, + }, + { + universalIdentifier: DUAL_B_SECOND_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'bSecondKey', + label: 'B Second Key', + isNullable: true, + }, + { + universalIdentifier: DUAL_PAYLOAD_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'payload', + label: 'Payload', + isNullable: true, + }, + ], +}); + +const manifest: Manifest = buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + objects: [OBJECT, DUAL_OBJECT], + fields: [], + indexes: [ + { + universalIdentifier: INDEX_ID, + objectUniversalIdentifier: OBJECT.universalIdentifier, + isUnique: true, + fields: [ + { + universalIdentifier: uuidv4(), + fieldUniversalIdentifier: FIRST_FIELD_ID, + }, + { + universalIdentifier: uuidv4(), + fieldUniversalIdentifier: SECOND_FIELD_ID, + }, + ], + }, + { + universalIdentifier: DUAL_INDEX_A_ID, + objectUniversalIdentifier: DUAL_OBJECT.universalIdentifier, + isUnique: true, + fields: [ + { + universalIdentifier: uuidv4(), + fieldUniversalIdentifier: DUAL_A_FIRST_FIELD_ID, + }, + { + universalIdentifier: uuidv4(), + fieldUniversalIdentifier: DUAL_A_SECOND_FIELD_ID, + }, + ], + }, + { + universalIdentifier: DUAL_INDEX_B_ID, + objectUniversalIdentifier: DUAL_OBJECT.universalIdentifier, + isUnique: true, + fields: [ + { + universalIdentifier: uuidv4(), + fieldUniversalIdentifier: DUAL_B_FIRST_FIELD_ID, + }, + { + universalIdentifier: uuidv4(), + fieldUniversalIdentifier: DUAL_B_SECOND_FIELD_ID, + }, + ], + }, + ], + }, +}); + +const GQL_FIELDS = ` + id + firstKey + secondKey + payload +`; + +const upsertRecords = (data: object[], upsert: boolean) => + makeGraphqlAPIRequest( + createManyOperationFactory({ + objectMetadataSingularName: OBJECT.nameSingular, + objectMetadataPluralName: OBJECT.namePlural, + gqlFields: GQL_FIELDS, + data, + upsert, + }), + ); + +const getRecords = (response: { body: { data: Record } }) => + response.body.data.createCompositeUpsertRecords as { + id: string; + firstKey: string; + secondKey: string; + payload: string; + }[]; + +const DUAL_GQL_FIELDS = ` + id + aFirstKey + aSecondKey + bFirstKey + bSecondKey + payload +`; + +const upsertDualRecords = (data: object[], upsert: boolean) => + makeGraphqlAPIRequest( + createManyOperationFactory({ + objectMetadataSingularName: DUAL_OBJECT.nameSingular, + objectMetadataPluralName: DUAL_OBJECT.namePlural, + gqlFields: DUAL_GQL_FIELDS, + data, + upsert, + }), + ); + +const getDualRecords = (response: { body: { data: Record } }) => + response.body.data.createDualCompositeUpsertRecords as { + id: string; + aFirstKey: string; + aSecondKey: string; + bFirstKey: string; + bSecondKey: string; + payload: string; + }[]; + +describe('upsert with composite unique index', () => { + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Composite Upsert App', + description: 'App for composite unique index upsert test', + sourcePath: 'test-composite-unique-upsert', + }); + + await syncApplication({ manifest, expectToFail: false }); + }, 60000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }, 60000); + + it('updates the existing record when the full composite key matches', async () => { + const recordId = uuidv4(); + + const created = getRecords( + await upsertRecords( + [{ id: recordId, firstKey: 'A', secondKey: 'B', payload: 'original' }], + false, + ), + )[0]; + + expect(created.id).toBe(recordId); + expect(created.payload).toBe('original'); + + + const upserted = getRecords( + await upsertRecords( + [{ firstKey: 'A', secondKey: 'B', payload: 'updated' }], + true, + ), + ); + + expect(upserted).toHaveLength(1); + expect(upserted[0].id).toBe(recordId); + expect(upserted[0].payload).toBe('updated'); + }, 60000); + + it('inserts a new record when only part of the composite key matches', async () => { + const existingId = uuidv4(); + const newId = uuidv4(); + + const created = getRecords( + await upsertRecords( + [{ id: existingId, firstKey: 'C', secondKey: 'D', payload: 'first' }], + false, + ), + )[0]; + + const upserted = getRecords( + await upsertRecords( + [{ id: newId, firstKey: 'C', secondKey: 'X', payload: 'second' }], + true, + ), + ); + + expect(upserted).toHaveLength(1); + expect(upserted[0].id).toBe(newId); + expect(upserted[0].id).not.toBe(created.id); + expect(upserted[0].payload).toBe('second'); + }, 60000); + + describe('with two composite unique indexes', () => { + it('updates the record when a single row matches on both composite indexes', async () => { + const recordId = uuidv4(); + + const created = getDualRecords( + await upsertDualRecords( + [ + { + id: recordId, + aFirstKey: 'a1', + aSecondKey: 'a2', + bFirstKey: 'b1', + bSecondKey: 'b2', + payload: 'original', + }, + ], + false, + ), + )[0]; + + expect(created.id).toBe(recordId); + expect(created.payload).toBe('original'); + + // The payload matches this single row through both composite indexes, + // so there is no ambiguity and the existing record is updated in place. + const upserted = getDualRecords( + await upsertDualRecords( + [ + { + aFirstKey: 'a1', + aSecondKey: 'a2', + bFirstKey: 'b1', + bSecondKey: 'b2', + payload: 'updated', + }, + ], + true, + ), + ); + + expect(upserted).toHaveLength(1); + expect(upserted[0].id).toBe(recordId); + expect(upserted[0].payload).toBe('updated'); + }, 60000); + + it('fails when the payload matches different rows across the two composite indexes', async () => { + const firstRecordId = uuidv4(); + const secondRecordId = uuidv4(); + + getDualRecords( + await upsertDualRecords( + [ + { + id: firstRecordId, + aFirstKey: 'c1', + aSecondKey: 'c2', + bFirstKey: 'd1', + bSecondKey: 'd2', + payload: 'first', + }, + { + id: secondRecordId, + aFirstKey: 'e1', + aSecondKey: 'e2', + bFirstKey: 'f1', + bSecondKey: 'f2', + payload: 'second', + }, + ], + false, + ), + ); + + // aFirst/aSecond match the first record via indexA while bFirst/bSecond + // match the second record via indexB. Two distinct rows match, so upsert + // cannot decide which one to update and must reject the operation. + const conflictingResponse = await upsertDualRecords( + [ + { + aFirstKey: 'c1', + aSecondKey: 'c2', + bFirstKey: 'f1', + bSecondKey: 'f2', + payload: 'conflicting', + }, + ], + true, + ); + + expect(conflictingResponse.body.errors).toBeDefined(); + expect(conflictingResponse.body.errors[0].message).toContain( + 'Multiple records found with the same unique field values', + ); + expect(conflictingResponse.body.errors[0].extensions.code).toBe( + 'BAD_USER_INPUT', + ); + }, 60000); + }); +});