Refactor graphql query runner and add mutation resolvers (#7418)

Fixes https://github.com/twentyhq/twenty/issues/6859

This PR adds all the remaining resolvers for
- updateOne/updateMany
- createOne/createMany
- deleteOne/deleteMany
- destroyOne
- restoreMany

Also
- refactored the graphql-query-runner to be able to add other resolvers
without too much boilerplate.
- add missing events that were not sent anymore as well as webhooks
- make resolver injectable so they can inject other services as well
- use objectMetadataMap from cache instead of computing it multiple time
- various fixes (mutation not correctly parsing JSON, relationHelper
fetching data with empty ids set, ...)

Next steps: 
- Wrapping query builder to handle DB events properly
- Move webhook emitters to db event listener
- Add pagination where it's missing (findDuplicates, nested relations,
etc...)
This commit is contained in:
Weiko
2024-10-04 11:58:33 +02:00
committed by GitHub
parent 8afa504b65
commit 511150a2d3
43 changed files with 1696 additions and 775 deletions

View File

@ -0,0 +1,188 @@
import {
Record as IRecord,
RecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { CONNECTION_MAX_DEPTH } from 'src/engine/api/graphql/graphql-query-runner/constants/connection-max-depth.constant';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { getRelationObjectMetadata } from 'src/engine/api/graphql/graphql-query-runner/utils/get-relation-object-metadata.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
import { isPlainObject } from 'src/utils/is-plain-object';
export class ObjectRecordsToGraphqlConnectionHelper {
private objectMetadataMap: ObjectMetadataMap;
constructor(objectMetadataMap: ObjectMetadataMap) {
this.objectMetadataMap = objectMetadataMap;
}
public createConnection<ObjectRecord extends IRecord = IRecord>(
objectRecords: ObjectRecord[],
objectName: string,
take: number,
totalCount: number,
order: RecordOrderBy | undefined,
hasNextPage: boolean,
hasPreviousPage: boolean,
depth = 0,
): IConnection<ObjectRecord> {
const edges = (objectRecords ?? []).map((objectRecord) => ({
node: this.processRecord(
objectRecord,
objectName,
take,
totalCount,
order,
depth,
),
cursor: encodeCursor(objectRecord, order),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: totalCount,
};
}
public processRecord<T extends Record<string, any>>(
objectRecord: T,
objectName: string,
take: number,
totalCount: number,
order?: RecordOrderBy,
depth = 0,
): T {
if (depth >= CONNECTION_MAX_DEPTH) {
throw new GraphqlQueryRunnerException(
`Maximum depth of ${CONNECTION_MAX_DEPTH} reached`,
GraphqlQueryRunnerExceptionCode.MAX_DEPTH_REACHED,
);
}
const objectMetadata = this.objectMetadataMap[objectName];
if (!objectMetadata) {
throw new GraphqlQueryRunnerException(
`Object metadata not found for ${objectName}`,
GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const processedObjectRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(objectRecord)) {
const fieldMetadata = objectMetadata.fields[key];
if (!fieldMetadata) {
processedObjectRecord[key] = value;
continue;
}
if (isRelationFieldMetadataType(fieldMetadata.type)) {
if (Array.isArray(value)) {
processedObjectRecord[key] = this.createConnection(
value,
getRelationObjectMetadata(fieldMetadata, this.objectMetadataMap)
.nameSingular,
take,
value.length,
order,
false,
false,
depth + 1,
);
} else if (isPlainObject(value)) {
processedObjectRecord[key] = this.processRecord(
value,
getRelationObjectMetadata(fieldMetadata, this.objectMetadataMap)
.nameSingular,
take,
totalCount,
order,
depth + 1,
);
}
} else if (isCompositeFieldMetadataType(fieldMetadata.type)) {
processedObjectRecord[key] = this.processCompositeField(
fieldMetadata,
value,
);
} else {
processedObjectRecord[key] = this.formatFieldValue(
value,
fieldMetadata.type,
);
}
}
return processedObjectRecord as T;
}
private processCompositeField(
fieldMetadata: FieldMetadataInterface,
fieldValue: any,
): Record<string, any> {
const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type as CompositeFieldMetadataType,
);
if (!compositeType) {
throw new Error(
`Composite type definition not found for type: ${fieldMetadata.type}`,
);
}
return Object.entries(fieldValue).reduce(
(acc, [subFieldKey, subFieldValue]) => {
if (subFieldKey === '__typename') return acc;
const subFieldMetadata = compositeType.properties.find(
(property) => property.name === subFieldKey,
);
if (!subFieldMetadata) {
throw new Error(
`Sub field metadata not found for composite type: ${fieldMetadata.type}`,
);
}
acc[subFieldKey] = this.formatFieldValue(
subFieldValue,
subFieldMetadata.type,
);
return acc;
},
{} as Record<string, any>,
);
}
private formatFieldValue(value: any, fieldType: FieldMetadataType) {
switch (fieldType) {
case FieldMetadataType.RAW_JSON:
return value ? JSON.stringify(value) : value;
case FieldMetadataType.DATE:
case FieldMetadataType.DATE_TIME:
return value instanceof Date ? value.toISOString() : value;
default:
return value;
}
}
}

View File

@ -4,6 +4,7 @@ import {
FindOptionsRelations,
In,
ObjectLiteral,
Repository,
} from 'typeorm';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
@ -16,14 +17,70 @@ import {
ObjectMetadataMap,
ObjectMetadataMapItem,
} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { deduceRelationDirection } from 'src/engine/utils/deduce-relation-direction.util';
export class ProcessNestedRelationsHelper {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager;
constructor() {}
constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
this.twentyORMGlobalManager = twentyORMGlobalManager;
public async processNestedRelations<ObjectRecord extends IRecord = IRecord>(
objectMetadataMap: ObjectMetadataMap,
parentObjectMetadataItem: ObjectMetadataMapItem,
parentObjectRecords: ObjectRecord[],
relations: Record<string, FindOptionsRelations<ObjectLiteral>>,
limit: number,
authContext: any,
dataSource: DataSource,
): Promise<void> {
const processRelationTasks = Object.entries(relations).map(
([relationName, nestedRelations]) =>
this.processRelation(
objectMetadataMap,
parentObjectMetadataItem,
parentObjectRecords,
relationName,
nestedRelations,
limit,
authContext,
dataSource,
),
);
await Promise.all(processRelationTasks);
}
private async processRelation<ObjectRecord extends IRecord = IRecord>(
objectMetadataMap: ObjectMetadataMap,
parentObjectMetadataItem: ObjectMetadataMapItem,
parentObjectRecords: ObjectRecord[],
relationName: string,
nestedRelations: any,
limit: number,
authContext: any,
dataSource: DataSource,
): Promise<void> {
const relationFieldMetadata = parentObjectMetadataItem.fields[relationName];
const relationMetadata = getRelationMetadata(relationFieldMetadata);
const relationDirection = deduceRelationDirection(
relationFieldMetadata,
relationMetadata,
);
const processor =
relationDirection === 'to'
? this.processToRelation
: this.processFromRelation;
await processor.call(
this,
objectMetadataMap,
parentObjectMetadataItem,
parentObjectRecords,
relationName,
nestedRelations,
limit,
authContext,
dataSource,
);
}
private async processFromRelation<ObjectRecord extends IRecord = IRecord>(
@ -35,49 +92,36 @@ export class ProcessNestedRelationsHelper {
limit: number,
authContext: any,
dataSource: DataSource,
) {
const relationFieldMetadata = parentObjectMetadataItem.fields[relationName];
const relationMetadata = getRelationMetadata(relationFieldMetadata);
const inverseRelationName =
objectMetadataMap[relationMetadata.toObjectMetadataId]?.fields[
relationMetadata.toFieldMetadataId
]?.name;
const referenceObjectMetadata = getRelationObjectMetadata(
relationFieldMetadata,
objectMetadataMap,
);
const referenceObjectMetadataName = referenceObjectMetadata.nameSingular;
const relationRepository = await dataSource.getRepository(
referenceObjectMetadataName,
);
const relationIds = parentObjectRecords.map((item) => item.id);
const uniqueRelationIds = [...new Set(relationIds)];
const relationFindOptions: FindManyOptions = {
where: {
[`${inverseRelationName}Id`]: In(uniqueRelationIds),
},
take: limit * parentObjectRecords.length,
};
const relationResults = await relationRepository.find(relationFindOptions);
parentObjectRecords.forEach((item) => {
(item as any)[relationName] = relationResults.filter(
(rel) => rel[`${inverseRelationName}Id`] === item.id,
): Promise<void> {
const { inverseRelationName, referenceObjectMetadata } =
this.getRelationMetadata(
objectMetadataMap,
parentObjectMetadataItem,
relationName,
);
});
const relationRepository = dataSource.getRepository(
referenceObjectMetadata.nameSingular,
);
const relationIds = this.getUniqueIds(parentObjectRecords, 'id');
const relationResults = await this.findRelations(
relationRepository,
inverseRelationName,
relationIds,
limit * parentObjectRecords.length,
);
this.assignRelationResults(
parentObjectRecords,
relationResults,
relationName,
`${inverseRelationName}Id`,
);
if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations(
objectMetadataMap,
objectMetadataMap[referenceObjectMetadataName],
objectMetadataMap[referenceObjectMetadata.nameSingular],
relationResults as ObjectRecord[],
nestedRelations as Record<string, FindOptionsRelations<ObjectLiteral>>,
limit,
@ -96,48 +140,37 @@ export class ProcessNestedRelationsHelper {
limit: number,
authContext: any,
dataSource: DataSource,
) {
const relationFieldMetadata = parentObjectMetadataItem.fields[relationName];
const referenceObjectMetadata = getRelationObjectMetadata(
relationFieldMetadata,
): Promise<void> {
const { referenceObjectMetadata } = this.getRelationMetadata(
objectMetadataMap,
parentObjectMetadataItem,
relationName,
);
const referenceObjectMetadataName = referenceObjectMetadata.nameSingular;
const relationRepository = dataSource.getRepository(
referenceObjectMetadataName,
referenceObjectMetadata.nameSingular,
);
const relationIds = parentObjectRecords.map(
(item) => item[`${relationName}Id`],
const relationIds = this.getUniqueIds(
parentObjectRecords,
`${relationName}Id`,
);
const relationResults = await this.findRelations(
relationRepository,
'id',
relationIds,
limit,
);
const uniqueRelationIds = [...new Set(relationIds)];
const relationFindOptions: FindManyOptions = {
where: {
id: In(uniqueRelationIds),
},
take: limit,
};
const relationResults = await relationRepository.find(relationFindOptions);
parentObjectRecords.forEach((item) => {
if (relationResults.length === 0) {
(item as any)[`${relationName}Id`] = null;
}
(item as any)[relationName] = relationResults.filter(
(rel) => rel.id === item[`${relationName}Id`],
)[0];
});
this.assignToRelationResults(
parentObjectRecords,
relationResults,
relationName,
);
if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations(
objectMetadataMap,
objectMetadataMap[referenceObjectMetadataName],
objectMetadataMap[referenceObjectMetadata.nameSingular],
relationResults as ObjectRecord[],
nestedRelations as Record<string, FindOptionsRelations<ObjectLiteral>>,
limit,
@ -147,48 +180,71 @@ export class ProcessNestedRelationsHelper {
}
}
public async processNestedRelations<ObjectRecord extends IRecord = IRecord>(
private getRelationMetadata(
objectMetadataMap: ObjectMetadataMap,
parentObjectMetadataItem: ObjectMetadataMapItem,
parentObjectRecords: ObjectRecord[],
relations: Record<string, FindOptionsRelations<ObjectLiteral>>,
limit: number,
authContext: any,
dataSource: DataSource,
relationName: string,
) {
for (const [relationName, nestedRelations] of Object.entries(relations)) {
const relationFieldMetadata =
parentObjectMetadataItem.fields[relationName];
const relationMetadata = getRelationMetadata(relationFieldMetadata);
const relationFieldMetadata = parentObjectMetadataItem.fields[relationName];
const relationMetadata = getRelationMetadata(relationFieldMetadata);
const referenceObjectMetadata = getRelationObjectMetadata(
relationFieldMetadata,
objectMetadataMap,
);
const inverseRelationName =
objectMetadataMap[relationMetadata.toObjectMetadataId]?.fields[
relationMetadata.toFieldMetadataId
]?.name;
const relationDirection = deduceRelationDirection(
relationFieldMetadata,
relationMetadata,
);
return { inverseRelationName, referenceObjectMetadata };
}
if (relationDirection === 'to') {
await this.processToRelation(
objectMetadataMap,
parentObjectMetadataItem,
parentObjectRecords,
relationName,
nestedRelations,
limit,
authContext,
dataSource,
);
} else {
await this.processFromRelation(
objectMetadataMap,
parentObjectMetadataItem,
parentObjectRecords,
relationName,
nestedRelations,
limit,
authContext,
dataSource,
);
}
private getUniqueIds(records: IRecord[], idField: string): any[] {
return [...new Set(records.map((item) => item[idField]))];
}
private async findRelations(
repository: Repository<any>,
field: string,
ids: any[],
limit: number,
): Promise<any[]> {
if (ids.length === 0) {
return [];
}
const findOptions: FindManyOptions = {
where: { [field]: In(ids) },
take: limit,
};
return repository.find(findOptions);
}
private assignRelationResults(
parentRecords: IRecord[],
relationResults: any[],
relationName: string,
joinField: string,
): void {
parentRecords.forEach((item) => {
(item as any)[relationName] = relationResults.filter(
(rel) => rel[joinField] === item.id,
);
});
}
private assignToRelationResults(
parentRecords: IRecord[],
relationResults: any[],
relationName: string,
): void {
parentRecords.forEach((item) => {
if (relationResults.length === 0) {
(item as any)[`${relationName}Id`] = null;
}
(item as any)[relationName] =
relationResults.find((rel) => rel.id === item[`${relationName}Id`]) ??
null;
});
}
}