feat: merge records (#13436)
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+2
@@ -13,6 +13,7 @@ import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/gr
|
||||
import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
|
||||
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
|
||||
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
|
||||
import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service';
|
||||
import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service';
|
||||
import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service';
|
||||
import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service';
|
||||
@@ -33,6 +34,7 @@ const graphqlQueryResolvers = [
|
||||
GraphqlQueryFindDuplicatesResolverService,
|
||||
GraphqlQueryFindManyResolverService,
|
||||
GraphqlQueryFindOneResolverService,
|
||||
GraphqlQueryMergeManyResolverService,
|
||||
GraphqlQueryRestoreManyResolverService,
|
||||
GraphqlQueryRestoreOneResolverService,
|
||||
GraphqlQueryUpdateManyResolverService,
|
||||
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
MUTATION_MAX_MERGE_RECORDS,
|
||||
QUERY_MAX_RECORDS,
|
||||
} from 'twenty-shared/constants';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
GraphqlQueryBaseResolverService,
|
||||
GraphqlQueryResolverExecutionArgs,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
|
||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
|
||||
import { MergeManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
import { FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
|
||||
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
|
||||
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
|
||||
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 { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolverService<
|
||||
MergeManyResolverArgs,
|
||||
ObjectRecord
|
||||
> {
|
||||
private readonly logger = new Logger(
|
||||
GraphqlQueryMergeManyResolverService.name,
|
||||
);
|
||||
|
||||
async resolve(
|
||||
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
|
||||
): Promise<ObjectRecord> {
|
||||
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
|
||||
executionArgs.options;
|
||||
const { ids, conflictPriorityIndex, dryRun } = executionArgs.args;
|
||||
const { roleId } = executionArgs;
|
||||
|
||||
const recordsToMerge = await this.fetchRecordsToMerge(executionArgs, ids);
|
||||
|
||||
const priorityRecord = this.validateAndGetPriorityRecord(
|
||||
recordsToMerge,
|
||||
ids,
|
||||
conflictPriorityIndex,
|
||||
);
|
||||
|
||||
const mergedData = this.performDeepMerge(
|
||||
recordsToMerge,
|
||||
priorityRecord.id,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
return this.createDryRunResponse(
|
||||
priorityRecord,
|
||||
mergedData,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
);
|
||||
}
|
||||
|
||||
const idsToDelete = ids.filter((id) => id !== priorityRecord.id);
|
||||
|
||||
await this.migrateRelatedRecords(
|
||||
executionArgs,
|
||||
idsToDelete,
|
||||
priorityRecord.id,
|
||||
);
|
||||
|
||||
const queryBuilder = executionArgs.repository.createQueryBuilder(
|
||||
objectMetadataItemWithFieldMaps.nameSingular,
|
||||
);
|
||||
|
||||
await queryBuilder
|
||||
.softDelete()
|
||||
.whereInIds(idsToDelete)
|
||||
.returning('*')
|
||||
.execute();
|
||||
|
||||
const updatedRecord = await this.updatePriorityRecord(
|
||||
executionArgs,
|
||||
priorityRecord.id,
|
||||
mergedData,
|
||||
);
|
||||
|
||||
if (roleId) {
|
||||
await this.processNestedRelations(
|
||||
executionArgs,
|
||||
[updatedRecord],
|
||||
authContext,
|
||||
roleId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.formatResponse(
|
||||
updatedRecord,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps,
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchRecordsToMerge(
|
||||
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
|
||||
ids: string[],
|
||||
): Promise<ObjectRecord[]> {
|
||||
const recordsToMerge = await executionArgs.repository.find({
|
||||
where: { id: In(ids) },
|
||||
});
|
||||
|
||||
if (recordsToMerge.length !== ids.length) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
'One or more records not found',
|
||||
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return recordsToMerge as ObjectRecord[];
|
||||
}
|
||||
|
||||
private validateAndGetPriorityRecord(
|
||||
recordsToMerge: ObjectRecord[],
|
||||
ids: string[],
|
||||
conflictPriorityIndex: number,
|
||||
): ObjectRecord {
|
||||
const priorityRecordId = ids[conflictPriorityIndex];
|
||||
const priorityRecord = recordsToMerge.find(
|
||||
(record) => record.id === priorityRecordId,
|
||||
);
|
||||
|
||||
if (!priorityRecord) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
'Priority record not found',
|
||||
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return priorityRecord;
|
||||
}
|
||||
|
||||
private performDeepMerge(
|
||||
recordsToMerge: ObjectRecord[],
|
||||
priorityRecordId: string,
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
|
||||
): Partial<ObjectRecord> {
|
||||
const mergedResult: Partial<ObjectRecord> = {};
|
||||
|
||||
const allFieldNames = new Set<string>();
|
||||
|
||||
recordsToMerge.forEach((record) => {
|
||||
Object.keys(record).forEach((fieldName) => {
|
||||
if (
|
||||
!this.shouldExcludeFieldFromMerge(
|
||||
fieldName,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
)
|
||||
) {
|
||||
allFieldNames.add(fieldName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
allFieldNames.forEach((fieldName) => {
|
||||
const recordsWithValues: { value: unknown; recordId: string }[] = [];
|
||||
|
||||
recordsToMerge.forEach((record) => {
|
||||
const fieldValue = record[fieldName];
|
||||
|
||||
if (hasRecordFieldValue(fieldValue)) {
|
||||
recordsWithValues.push({ value: fieldValue, recordId: record.id });
|
||||
}
|
||||
});
|
||||
|
||||
if (recordsWithValues.length === 0) {
|
||||
return;
|
||||
} else if (recordsWithValues.length === 1) {
|
||||
mergedResult[fieldName] = recordsWithValues[0].value;
|
||||
} else {
|
||||
const priorityValue = recordsWithValues.find(
|
||||
(item) => item.recordId === priorityRecordId,
|
||||
);
|
||||
|
||||
if (priorityValue) {
|
||||
mergedResult[fieldName] = priorityValue.value;
|
||||
} else {
|
||||
mergedResult[fieldName] = recordsWithValues[0].value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return mergedResult;
|
||||
}
|
||||
|
||||
private shouldExcludeFieldFromMerge(
|
||||
fieldName: string,
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
|
||||
): boolean {
|
||||
const fieldMetadata = Object.values(
|
||||
objectMetadataItemWithFieldMaps.fieldsById,
|
||||
).find((field) => field?.name === fieldName);
|
||||
|
||||
if (fieldMetadata?.isSystem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private createDryRunResponse(
|
||||
priorityRecord: ObjectRecord,
|
||||
mergedData: Partial<ObjectRecord>,
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
): ObjectRecord {
|
||||
const dryRunRecord = {
|
||||
...priorityRecord,
|
||||
...mergedData,
|
||||
id: uuidv4(),
|
||||
deletedAt: new Date().toISOString(),
|
||||
} as ObjectRecord;
|
||||
|
||||
const typeORMObjectRecordsParser =
|
||||
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
|
||||
|
||||
return typeORMObjectRecordsParser.processRecord({
|
||||
objectRecord: dryRunRecord,
|
||||
objectName: objectMetadataItemWithFieldMaps.nameSingular,
|
||||
take: 1,
|
||||
totalCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
private async updatePriorityRecord(
|
||||
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
|
||||
priorityRecordId: string,
|
||||
mergedData: Partial<ObjectRecord>,
|
||||
): Promise<ObjectRecord> {
|
||||
const { objectMetadataItemWithFieldMaps } = executionArgs.options;
|
||||
|
||||
const queryBuilder = executionArgs.repository.createQueryBuilder(
|
||||
objectMetadataItemWithFieldMaps.nameSingular,
|
||||
);
|
||||
|
||||
const columnsToReturn = buildColumnsToReturn({
|
||||
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
|
||||
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
|
||||
objectMetadataItemWithFieldMaps,
|
||||
});
|
||||
|
||||
const updatedObjectRecords = await queryBuilder
|
||||
.update()
|
||||
.set(mergedData)
|
||||
.where({ id: priorityRecordId })
|
||||
.returning(columnsToReturn)
|
||||
.execute();
|
||||
|
||||
if (!updatedObjectRecords.generatedMaps.length) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
'Failed to update record',
|
||||
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedRecord = updatedObjectRecords.generatedMaps[0] as ObjectRecord;
|
||||
|
||||
return updatedRecord;
|
||||
}
|
||||
|
||||
private async migrateRelatedRecords(
|
||||
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
|
||||
fromIds: string[],
|
||||
toId: string,
|
||||
): Promise<void> {
|
||||
const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
|
||||
executionArgs.options;
|
||||
|
||||
const relationFieldsPointingToCurrentObject = Object.values(
|
||||
objectMetadataMaps.byId,
|
||||
)
|
||||
.filter(isDefined)
|
||||
.flatMap((metadata) => {
|
||||
const relationFields = Object.values(metadata.fieldsById)
|
||||
.filter(isDefined)
|
||||
.filter((field) =>
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION),
|
||||
)
|
||||
.filter(
|
||||
(field) =>
|
||||
field.relationTargetObjectMetadataId ===
|
||||
objectMetadataItemWithFieldMaps.id && field.isActive,
|
||||
);
|
||||
|
||||
return relationFields
|
||||
.filter((field) => {
|
||||
const relationSettings =
|
||||
field.settings as FieldMetadataRelationSettings;
|
||||
|
||||
return (
|
||||
relationSettings?.relationType === RelationType.MANY_TO_ONE &&
|
||||
relationSettings?.joinColumnName
|
||||
);
|
||||
})
|
||||
.map((field) => {
|
||||
const relationSettings =
|
||||
field.settings as FieldMetadataRelationSettings;
|
||||
|
||||
return {
|
||||
objectMetadata: metadata,
|
||||
fieldName: field.name,
|
||||
fieldId: field.id,
|
||||
joinColumnName: relationSettings.joinColumnName,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
for (const relationField of relationFieldsPointingToCurrentObject) {
|
||||
if (!relationField.joinColumnName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = executionArgs.workspaceDataSource.getRepository(
|
||||
relationField.objectMetadata.nameSingular,
|
||||
executionArgs.isExecutedByApiKey,
|
||||
executionArgs.roleId,
|
||||
);
|
||||
|
||||
const whereCondition = { [relationField.joinColumnName]: In(fromIds) };
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: whereCondition,
|
||||
});
|
||||
|
||||
if (existingRecords.length > 0) {
|
||||
await repository.update(whereCondition, {
|
||||
[relationField.joinColumnName]: toId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to migrate relation field "${relationField.fieldName}" (${relationField.joinColumnName}) in object "${relationField.objectMetadata.nameSingular}":`,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processNestedRelations(
|
||||
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
|
||||
updatedRecords: ObjectRecord[],
|
||||
authContext: AuthContext,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
|
||||
executionArgs.options;
|
||||
|
||||
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
|
||||
await this.processNestedRelationsHelper.processNestedRelations({
|
||||
objectMetadataMaps,
|
||||
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
|
||||
parentObjectRecords: updatedRecords,
|
||||
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
|
||||
limit: QUERY_MAX_RECORDS,
|
||||
authContext,
|
||||
workspaceDataSource: executionArgs.workspaceDataSource,
|
||||
roleId,
|
||||
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
|
||||
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private formatResponse(
|
||||
updatedRecord: ObjectRecord,
|
||||
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
): ObjectRecord {
|
||||
const typeORMObjectRecordsParser =
|
||||
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
|
||||
|
||||
return typeORMObjectRecordsParser.processRecord({
|
||||
objectRecord: updatedRecord,
|
||||
objectName: objectMetadataItemWithFieldMaps.nameSingular,
|
||||
take: 1,
|
||||
totalCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
args: MergeManyResolverArgs,
|
||||
options: WorkspaceQueryRunnerOptions,
|
||||
): Promise<void> {
|
||||
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
|
||||
|
||||
if (!isDefined(options.objectMetadataItemWithFieldMaps.duplicateCriteria)) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Merge is only available for objects with duplicate criteria. Object '${options.objectMetadataItemWithFieldMaps.nameSingular}' does not have duplicate criteria defined.`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const { ids, conflictPriorityIndex } = args;
|
||||
|
||||
if (!ids || ids.length < 2) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
'At least 2 record IDs are required for merge',
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (ids.length > MUTATION_MAX_MERGE_RECORDS) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Maximum ${MUTATION_MAX_MERGE_RECORDS} records can be merged at once`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (conflictPriorityIndex < 0 || conflictPriorityIndex >= ids.length) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Invalid conflict priority '${conflictPriorityIndex}'. Valid options for ${ids.length} records: 0-${ids.length - 1}`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
export const hasRecordFieldValue = (value: unknown): boolean => {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== '';
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return !isNaN(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const objectValue = value as Record<string, unknown>;
|
||||
|
||||
return Object.values(objectValue).some((val) => hasRecordFieldValue(val));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
+17
@@ -14,6 +14,7 @@ import {
|
||||
FindDuplicatesResolverArgs,
|
||||
FindManyResolverArgs,
|
||||
FindOneResolverArgs,
|
||||
MergeManyResolverArgs,
|
||||
ResolverArgs,
|
||||
ResolverArgsType,
|
||||
UpdateManyResolverArgs,
|
||||
@@ -145,6 +146,22 @@ export class QueryRunnerArgsFactory {
|
||||
) ?? [],
|
||||
),
|
||||
} satisfies FindDuplicatesResolverArgs;
|
||||
case ResolverArgsType.MergeMany:
|
||||
return {
|
||||
...args,
|
||||
ids: (await Promise.all(
|
||||
(args as MergeManyResolverArgs).ids?.map((id) =>
|
||||
this.overrideValueByFieldMetadata(
|
||||
'id',
|
||||
id,
|
||||
fieldMetadataMapByNameByName,
|
||||
),
|
||||
) ?? [],
|
||||
)) as string[],
|
||||
conflictPriorityIndex: (args as MergeManyResolverArgs)
|
||||
.conflictPriorityIndex,
|
||||
dryRun: (args as MergeManyResolverArgs).dryRun,
|
||||
} satisfies MergeManyResolverArgs;
|
||||
default:
|
||||
return args;
|
||||
}
|
||||
|
||||
+4
-1
@@ -8,6 +8,7 @@ import {
|
||||
FindDuplicatesResolverArgs,
|
||||
FindManyResolverArgs,
|
||||
FindOneResolverArgs,
|
||||
MergeManyResolverArgs,
|
||||
RestoreManyResolverArgs,
|
||||
UpdateManyResolverArgs,
|
||||
UpdateOneResolverArgs,
|
||||
@@ -42,4 +43,6 @@ export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
|
||||
? DestroyManyResolverArgs
|
||||
: T extends 'destroyOne'
|
||||
? DestroyOneResolverArgs
|
||||
: never;
|
||||
: T extends 'mergeMany'
|
||||
? MergeManyResolverArgs
|
||||
: never;
|
||||
|
||||
+1
@@ -12,4 +12,5 @@ export const RESOLVER_METHOD_NAMES = {
|
||||
RESTORE_ONE: 'restoreOne',
|
||||
DESTROY_MANY: 'destroyMany',
|
||||
DESTROY_ONE: 'destroyOne',
|
||||
MERGE_MANY: 'mergeMany',
|
||||
} as const;
|
||||
|
||||
+3
@@ -11,6 +11,7 @@ import { DeleteOneResolverFactory } from './delete-one-resolver.factory';
|
||||
import { FindDuplicatesResolverFactory } from './find-duplicates-resolver.factory';
|
||||
import { FindManyResolverFactory } from './find-many-resolver.factory';
|
||||
import { FindOneResolverFactory } from './find-one-resolver.factory';
|
||||
import { MergeManyResolverFactory } from './merge-many-resolver.factory';
|
||||
import { UpdateOneResolverFactory } from './update-one-resolver.factory';
|
||||
|
||||
export const workspaceResolverBuilderFactories = [
|
||||
@@ -27,6 +28,7 @@ export const workspaceResolverBuilderFactories = [
|
||||
DestroyManyResolverFactory,
|
||||
RestoreOneResolverFactory,
|
||||
RestoreManyResolverFactory,
|
||||
MergeManyResolverFactory,
|
||||
];
|
||||
|
||||
export const workspaceResolverBuilderMethodNames = {
|
||||
@@ -46,5 +48,6 @@ export const workspaceResolverBuilderMethodNames = {
|
||||
DestroyManyResolverFactory.methodName,
|
||||
RestoreOneResolverFactory.methodName,
|
||||
RestoreManyResolverFactory.methodName,
|
||||
MergeManyResolverFactory.methodName,
|
||||
],
|
||||
} as const;
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
|
||||
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
|
||||
import {
|
||||
MergeManyResolverArgs,
|
||||
Resolver,
|
||||
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
|
||||
|
||||
import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service';
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
|
||||
@Injectable()
|
||||
export class MergeManyResolverFactory
|
||||
implements WorkspaceResolverBuilderFactoryInterface
|
||||
{
|
||||
public static methodName = RESOLVER_METHOD_NAMES.MERGE_MANY;
|
||||
|
||||
constructor(
|
||||
private readonly graphqlQueryRunnerService: GraphqlQueryMergeManyResolverService,
|
||||
) {}
|
||||
|
||||
create(
|
||||
context: WorkspaceSchemaBuilderContext,
|
||||
): Resolver<MergeManyResolverArgs> {
|
||||
const internalContext = context;
|
||||
|
||||
return async (_source, args, _context, info) => {
|
||||
const options: WorkspaceQueryRunnerOptions = {
|
||||
authContext: internalContext.authContext,
|
||||
info,
|
||||
objectMetadataMaps: internalContext.objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps:
|
||||
internalContext.objectMetadataItemWithFieldMaps,
|
||||
};
|
||||
|
||||
return await this.graphqlQueryRunnerService.execute(
|
||||
args,
|
||||
options,
|
||||
MergeManyResolverFactory.methodName,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
+8
@@ -26,6 +26,7 @@ export enum ResolverArgsType {
|
||||
RestoreMany = 'RestoreMany',
|
||||
DestroyMany = 'DestroyMany',
|
||||
DestroyOne = 'DestroyOne',
|
||||
MergeMany = 'MergeMany',
|
||||
}
|
||||
|
||||
export interface FindManyResolverArgs<
|
||||
@@ -99,6 +100,12 @@ export interface RestoreManyResolverArgs<Filter = any> {
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
export interface MergeManyResolverArgs {
|
||||
ids: string[];
|
||||
conflictPriorityIndex: number;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface DestroyOneResolverArgs {
|
||||
id: string;
|
||||
}
|
||||
@@ -132,6 +139,7 @@ export type ResolverArgs =
|
||||
| FindDuplicatesResolverArgs
|
||||
| FindManyResolverArgs
|
||||
| FindOneResolverArgs
|
||||
| MergeManyResolverArgs
|
||||
| RestoreManyResolverArgs
|
||||
| RestoreOneResolverArgs
|
||||
| UpdateManyResolverArgs
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory';
|
||||
import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,6 +19,8 @@ export class WorkspaceResolverBuilderService {
|
||||
switch (methodName) {
|
||||
case FindDuplicatesResolverFactory.methodName:
|
||||
return isDefined(objectMetadata.duplicateCriteria);
|
||||
case MergeManyResolverFactory.methodName:
|
||||
return isDefined(objectMetadata.duplicateCriteria);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
+16
-6
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory';
|
||||
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory';
|
||||
import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory';
|
||||
import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
|
||||
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
|
||||
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
|
||||
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
|
||||
@@ -52,6 +53,7 @@ export class WorkspaceResolverFactory {
|
||||
private readonly restoreOneResolverFactory: RestoreOneResolverFactory,
|
||||
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
|
||||
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
|
||||
private readonly mergeManyResolverFactory: MergeManyResolverFactory,
|
||||
private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
@@ -74,6 +76,7 @@ export class WorkspaceResolverFactory {
|
||||
['findDuplicates', this.findDuplicatesResolverFactory],
|
||||
['findMany', this.findManyResolverFactory],
|
||||
['findOne', this.findOneResolverFactory],
|
||||
['mergeMany', this.mergeManyResolverFactory],
|
||||
['restoreMany', this.restoreManyResolverFactory],
|
||||
['restoreOne', this.restoreOneResolverFactory],
|
||||
['updateMany', this.updateManyResolverFactory],
|
||||
@@ -154,12 +157,19 @@ export class WorkspaceResolverFactory {
|
||||
throw new Error(`Unknown mutation resolver type: ${methodName}`);
|
||||
}
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
resolvers.Mutation[resolverName] = resolverFactory.create({
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps: objectMetadata,
|
||||
});
|
||||
if (
|
||||
this.workspaceResolverBuilderService.shouldBuildResolver(
|
||||
objectMetadata,
|
||||
methodName,
|
||||
)
|
||||
) {
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
resolvers.Mutation[resolverName] = resolverFactory.create({
|
||||
authContext,
|
||||
objectMetadataMaps,
|
||||
objectMetadataItemWithFieldMaps: objectMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -145,6 +145,22 @@ export const getResolverArgs = (
|
||||
isNullable: false,
|
||||
},
|
||||
};
|
||||
case 'mergeMany':
|
||||
return {
|
||||
ids: {
|
||||
type: UUIDScalarType,
|
||||
isNullable: false,
|
||||
isArray: true,
|
||||
},
|
||||
conflictPriorityIndex: {
|
||||
type: GraphQLInt,
|
||||
isNullable: false,
|
||||
},
|
||||
dryRun: {
|
||||
type: GraphQLBoolean,
|
||||
isNullable: true,
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown resolver type: ${type}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user