add WorkspaceDuplicateCriteria decorator + update duplicate resolver logic (#10128)

## Context

All objects have '...duplicates' resolver but only companies and people
have duplicate criteria (hard coded constant).
Gql schema and resolver should be created only if duplicate criteria
exist.

## Solution

- Add a new @WorkspaceDuplicateCriteria decorator at object level,
defining duplicate criteria for given object.
- Add a new duplicate criteria field in ObjectMetadata table
- Update schema and resolver building logic
- Update front requests for duplicate check (only for object with
criteria defined)



closes https://github.com/twentyhq/twenty/issues/9828
This commit is contained in:
Etienne
2025-02-12 17:32:59 +01:00
committed by GitHub
parent b66289c44c
commit 0609b31c64
29 changed files with 491 additions and 121 deletions

View File

@ -0,0 +1,93 @@
import { FieldMetadataType } from 'twenty-shared';
import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const mockPersonObjectMetadata = (
duplicateCriteria: WorkspaceEntityDuplicateCriteria[],
): ObjectMetadataItemWithFieldMaps => ({
id: '',
standardId: '',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
duplicateCriteria: duplicateCriteria,
fromRelations: [],
toRelations: [],
labelIdentifierFieldMetadataId: '',
imageIdentifierFieldMetadataId: '',
workspaceId: '',
fields: [],
indexMetadatas: [],
fieldsById: {},
fieldsByName: {
name: {
id: '',
objectMetadataId: '',
type: FieldMetadataType.FULL_NAME,
name: 'name',
label: 'Name',
defaultValue: {
lastName: "''",
firstName: "''",
},
description: 'Contacts name',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
emails: {
id: '',
objectMetadataId: '',
type: FieldMetadataType.EMAILS,
name: 'emails',
label: 'Emails',
defaultValue: {
primaryEmail: "''",
additionalEmails: null,
},
description: 'Contacts Emails',
isCustom: false,
workspaceId: '',
},
linkedinLink: {
id: '',
objectMetadataId: '',
type: FieldMetadataType.LINKS,
name: 'linkedinLink',
label: 'Linkedin',
defaultValue: {
primaryLinkUrl: "''",
secondaryLinks: "'[]'",
primaryLinkLabel: "''",
},
description: 'Contacts Linkedin account',
isCustom: false,
isNullable: true,
isUnique: false,
workspaceId: '',
},
jobTitle: {
id: '',
objectMetadataId: '',
type: FieldMetadataType.TEXT,
name: 'jobTitle',
label: 'Job Title',
defaultValue: "''",
description: 'Contacts job title',
isCustom: false,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
},
},
});

View File

@ -0,0 +1,20 @@
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
export const mockPersonRecords: Partial<ObjectRecord>[] = [
{
name: {
firstName: 'Testfirst',
lastName: 'Testlast',
},
emails: {
primaryEmail: 'test@test.fr',
additionalEmails: [],
},
linkedinLink: {
primaryLinkLabel: '',
primaryLinkUrl: '',
secondaryLinks: [],
},
jobTitle: 'Test job',
},
];

View File

@ -0,0 +1,125 @@
import { Test, TestingModule } from '@nestjs/testing';
import { mockPersonObjectMetadata } from 'src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata';
import { mockPersonRecords } from 'src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonRecords';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/query-result-getters.factory';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
describe('GraphqlQueryFindDuplicatesResolverService', () => {
let service: GraphqlQueryFindDuplicatesResolverService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GraphqlQueryFindDuplicatesResolverService,
WorkspaceQueryHookService,
QueryRunnerArgsFactory,
QueryResultGettersFactory,
ApiEventEmitterService,
TwentyORMGlobalManager,
ProcessNestedRelationsHelper,
FeatureFlagService,
PermissionsService,
],
})
.overrideProvider(WorkspaceQueryHookService)
.useValue({})
.overrideProvider(QueryRunnerArgsFactory)
.useValue({})
.overrideProvider(QueryResultGettersFactory)
.useValue({})
.overrideProvider(ApiEventEmitterService)
.useValue({})
.overrideProvider(TwentyORMGlobalManager)
.useValue({})
.overrideProvider(ProcessNestedRelationsHelper)
.useValue({})
.overrideProvider(FeatureFlagService)
.useValue({})
.overrideProvider(PermissionsService)
.useValue({})
.compile();
service = module.get<GraphqlQueryFindDuplicatesResolverService>(
GraphqlQueryFindDuplicatesResolverService,
);
});
describe('buildDuplicateConditions', () => {
it('should build conditions based on duplicate criteria from composite field', () => {
const duplicateConditons = service.buildDuplicateConditions(
mockPersonObjectMetadata([['emailsPrimaryEmail']]),
mockPersonRecords,
'recordId',
);
expect(duplicateConditons).toEqual({
or: [
{
emailsPrimaryEmail: {
eq: 'test@test.fr',
},
},
],
id: {
neq: 'recordId',
},
});
});
it('should build conditions based on duplicate criteria from basic field', () => {
const duplicateConditons = service.buildDuplicateConditions(
mockPersonObjectMetadata([['jobTitle']]),
mockPersonRecords,
'recordId',
);
expect(duplicateConditons).toEqual({
or: [
{
jobTitle: {
eq: 'Test job',
},
},
],
id: {
neq: 'recordId',
},
});
});
it('should not build conditions based on duplicate criteria if record value is null or too small', () => {
const duplicateConditons = service.buildDuplicateConditions(
mockPersonObjectMetadata([['linkedinLinkPrimaryLinkUrl']]),
mockPersonRecords,
'recordId',
);
expect(duplicateConditons).toEqual({});
});
it('should build conditions based on duplicate criteria and without recordId filter', () => {
const duplicateConditons = service.buildDuplicateConditions(
mockPersonObjectMetadata([['jobTitle']]),
mockPersonRecords,
);
expect(duplicateConditons).toEqual({
or: [
{
jobTitle: {
eq: 'Test job',
},
},
],
});
});
});
});

View File

@ -23,11 +23,13 @@ import {
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 { settings } from 'src/engine/constants/settings';
import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import {
formatResult,
getCompositeFieldMetadataMap,
} from 'src/engine/twenty-orm/utils/format-result.util';
@Injectable()
export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseResolverService<
@ -149,7 +151,7 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR
return duplicateConnections;
}
private buildDuplicateConditions(
buildDuplicateConditions(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
records?: Partial<ObjectRecord>[] | undefined,
filteringByExistingRecordId?: string,
@ -158,13 +160,21 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR
return {};
}
const criteriaCollection = this.getApplicableDuplicateCriteriaCollection(
const criteriaCollection =
objectMetadataItemWithFieldMaps.duplicateCriteria || [];
const formattedRecords = formatData(
records,
objectMetadataItemWithFieldMaps,
);
const conditions = records.flatMap((record) => {
const compositeFieldMetadataMap = getCompositeFieldMetadataMap(
objectMetadataItemWithFieldMaps,
);
const conditions = formattedRecords.flatMap((record) => {
const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) =>
criteria.columnNames.every((columnName) => {
criteria.every((columnName) => {
const value = record[columnName] as string | undefined;
return (
@ -176,8 +186,18 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR
return criteriaWithMatchingArgs.map((criteria) => {
const condition = {};
criteria.columnNames.forEach((columnName) => {
condition[columnName] = { eq: record[columnName] };
criteria.forEach((columnName) => {
const compositeFieldMetadata =
compositeFieldMetadataMap.get(columnName);
if (compositeFieldMetadata) {
condition[compositeFieldMetadata.parentField] = {
...condition[compositeFieldMetadata.parentField],
[compositeFieldMetadata.name]: { eq: record[columnName] },
};
} else {
condition[columnName] = { eq: record[columnName] };
}
});
return condition;
@ -197,16 +217,6 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR
return filter;
}
private getApplicableDuplicateCriteriaCollection(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
return DUPLICATE_CRITERIA_COLLECTION.filter(
(duplicateCriteria) =>
duplicateCriteria.objectName ===
objectMetadataItemWithFieldMaps.nameSingular,
);
}
async validate(
args: FindDuplicatesResolverArgs,
_options: WorkspaceQueryRunnerOptions,

View File

@ -0,0 +1,3 @@
type columnName = string;
export type WorkspaceEntityDuplicateCriteria = columnName[];

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { GraphqlQueryRunnerModule } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceResolverFactory } from './workspace-resolver.factory';
@ -14,7 +15,11 @@ import { workspaceResolverBuilderFactories } from './factories/factories';
GraphqlQueryRunnerModule,
FeatureFlagModule,
],
providers: [...workspaceResolverBuilderFactories, WorkspaceResolverFactory],
exports: [WorkspaceResolverFactory],
providers: [
...workspaceResolverBuilderFactories,
WorkspaceResolverFactory,
WorkspaceResolverBuilderService,
],
exports: [WorkspaceResolverFactory, WorkspaceResolverBuilderService],
})
export class WorkspaceResolverBuilderModule {}

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared';
import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory';
@Injectable()
export class WorkspaceResolverBuilderService {
constructor() {}
shouldBuildResolver(
objectMetadata: ObjectMetadataInterface,
methodName: WorkspaceResolverBuilderMethodNames,
) {
switch (methodName) {
case FindDuplicatesResolverFactory.methodName:
return isDefined(objectMetadata.duplicateCriteria);
default:
return true;
}
}
}

View File

@ -9,6 +9,7 @@ import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-res
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
import { SearchResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
@ -45,6 +46,7 @@ export class WorkspaceResolverFactory {
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
private readonly searchResolverFactory: SearchResolverFactory,
private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService,
) {}
async create(
@ -92,11 +94,18 @@ export class WorkspaceResolverFactory {
throw new Error(`Unknown query resolver type: ${methodName}`);
}
resolvers.Query[resolverName] = resolverFactory.create({
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps: objectMetadata,
});
if (
this.workspaceResolverBuilderService.shouldBuildResolver(
objectMetadata,
methodName,
)
) {
resolvers.Query[resolverName] = resolverFactory.create({
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps: objectMetadata,
});
}
}
// Generate mutation resolvers

View File

@ -6,6 +6,7 @@ import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/work
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { getResolverArgs } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util';
@ -28,6 +29,7 @@ export class RootTypeFactory {
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
private readonly typeMapperService: TypeMapperService,
private readonly argsFactory: ArgsFactory,
private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService,
) {}
create(
@ -70,53 +72,60 @@ export class RootTypeFactory {
for (const objectMetadata of objectMetadataCollection) {
for (const methodName of workspaceResolverMethodNames) {
const name = getResolverName(objectMetadata, methodName);
const args = getResolverArgs(methodName);
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
this.getObjectTypeDefinitionKindByMethodName(methodName),
);
const argsType = this.argsFactory.create(
{
args,
objectMetadataId: objectMetadata.id,
},
options,
);
if (!objectType) {
this.logger.error(
`Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`,
if (
this.workspaceResolverBuilderService.shouldBuildResolver(
objectMetadata,
methodName,
)
) {
const name = getResolverName(objectMetadata, methodName);
const args = getResolverArgs(methodName);
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
this.getObjectTypeDefinitionKindByMethodName(methodName),
);
const argsType = this.argsFactory.create(
{
objectMetadata,
methodName,
options,
args,
objectMetadataId: objectMetadata.id,
},
options,
);
throw new Error(
`Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`,
);
if (!objectType) {
this.logger.error(
`Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`,
{
objectMetadata,
methodName,
options,
},
);
throw new Error(
`Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`,
);
}
const allowedMethodNames = [
'updateMany',
'deleteMany',
'createMany',
'findDuplicates',
'restoreMany',
'destroyMany',
];
const outputType = this.typeMapperService.mapToGqlType(objectType, {
isArray: allowedMethodNames.includes(methodName),
});
fieldConfigMap[name] = {
type: outputType,
args: argsType,
resolve: undefined,
};
}
const allowedMethodNames = [
'updateMany',
'deleteMany',
'createMany',
'findDuplicates',
'restoreMany',
'destroyMany',
];
const outputType = this.typeMapperService.mapToGqlType(objectType, {
isArray: allowedMethodNames.includes(methodName),
});
fieldConfigMap[name] = {
type: outputType,
args: argsType,
resolve: undefined,
};
}
}

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
@ -11,7 +12,11 @@ import { TypeMapperService } from './services/type-mapper.service';
import { TypeDefinitionsStorage } from './storages/type-definitions.storage';
@Module({
imports: [ObjectMetadataModule, FeatureFlagModule],
imports: [
ObjectMetadataModule,
FeatureFlagModule,
WorkspaceResolverBuilderModule,
],
providers: [
TypeDefinitionsStorage,
TypeMapperService,