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:
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user