From 6e5487ed768061a6b23ec59df30107b451651aeb Mon Sep 17 00:00:00 2001 From: Guillim Date: Thu, 17 Jul 2025 23:06:45 +0200 Subject: [PATCH] morph dataloader specific (#13259) In the metadata GraphQL api, we need the resolveField for the morphRelations. This PR implements this topic. Fixes https://github.com/twentyhq/core-team-issues/issues/1234 --------- Co-authored-by: Charles Bochet --- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.ts | 1 + .../dataloaders/dataloader.interface.ts | 11 ++ .../engine/dataloaders/dataloader.service.ts | 54 +++++++- ...er-morph-relation-duplicate-fields.util.ts | 13 ++ .../field-metadata/field-metadata.module.ts | 1 + .../field-metadata/field-metadata.resolver.ts | 41 ++++++ .../field-metadata-morph-relation.service.ts | 126 +++++++++++++++++- ...morph-relation-field-metadata-type.util.ts | 7 + 9 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-server/src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util.ts create mode 100644 packages/twenty-server/src/engine/utils/is-morph-relation-field-metadata-type.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 299a09614..047faeb17 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -758,6 +758,7 @@ export type Field = { isSystem?: Maybe; isUnique?: Maybe; label: Scalars['String']; + morphRelations?: Maybe>; name: Scalars['String']; object?: Maybe; options?: Maybe; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 7e7bb76f6..1ffaa18b2 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -722,6 +722,7 @@ export type Field = { isSystem?: Maybe; isUnique?: Maybe; label: Scalars['String']; + morphRelations?: Maybe>; name: Scalars['String']; object?: Maybe; options?: Maybe; diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts index 7b7249b10..97a1e31cc 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.interface.ts @@ -4,6 +4,7 @@ import { FieldMetadataLoaderPayload, IndexFieldMetadataLoaderPayload, IndexMetadataLoaderPayload, + MorphRelationLoaderPayload, RelationLoaderPayload, } from 'src/engine/dataloaders/dataloader.service'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; @@ -23,6 +24,16 @@ export interface IDataloaders { } >; + morphRelationLoader: DataLoader< + MorphRelationLoaderPayload, + { + sourceObjectMetadata: ObjectMetadataEntity; + targetObjectMetadata: ObjectMetadataEntity; + sourceFieldMetadata: FieldMetadataEntity; + targetFieldMetadata: FieldMetadataEntity; + }[] + >; + fieldMetadataLoader: DataLoader< FieldMetadataLoaderPayload, FieldMetadataDTO[] diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts index 1c59198dd..15c25e1df 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts @@ -9,8 +9,10 @@ import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metad import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface'; import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; +import { filterMorphRelationDuplicateFieldsDTO } from 'src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util'; import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto'; @@ -38,6 +40,19 @@ export type RelationLoaderPayload = { >; }; +export type MorphRelationLoaderPayload = { + workspaceId: string; + fieldMetadata: Pick< + FieldMetadataInterface, + | 'type' + | 'id' + | 'objectMetadataId' + | 'relationTargetFieldMetadataId' + | 'relationTargetObjectMetadataId' + | 'name' + >; +}; + export type FieldMetadataLoaderPayload = { workspaceId: string; objectMetadata: Pick; @@ -59,17 +74,20 @@ export type IndexFieldMetadataLoaderPayload = { export class DataloaderService { constructor( private readonly fieldMetadataRelationService: FieldMetadataRelationService, + private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, ) {} createLoaders(): IDataloaders { const relationLoader = this.createRelationLoader(); + const morphRelationLoader = this.createMorphRelationLoader(); const fieldMetadataLoader = this.createFieldMetadataLoader(); const indexMetadataLoader = this.createIndexMetadataLoader(); const indexFieldMetadataLoader = this.createIndexFieldMetadataLoader(); return { relationLoader, + morphRelationLoader, fieldMetadataLoader, indexMetadataLoader, indexFieldMetadataLoader, @@ -101,6 +119,38 @@ export class DataloaderService { }); } + private createMorphRelationLoader() { + return new DataLoader< + MorphRelationLoaderPayload, + { + sourceObjectMetadata: ObjectMetadataEntity; + targetObjectMetadata: ObjectMetadataEntity; + sourceFieldMetadata: FieldMetadataEntity; + targetFieldMetadata: FieldMetadataEntity; + }[] + >(async (dataLoaderParams: MorphRelationLoaderPayload[]) => { + const workspaceId = dataLoaderParams[0].workspaceId; + + const fieldMetadataItems = dataLoaderParams.map( + (dataLoaderParam) => dataLoaderParam.fieldMetadata, + ); + + const fieldMetadataMorphRelationCollection = + await this.fieldMetadataMorphRelationService.findCachedFieldMetadataMorphRelation( + fieldMetadataItems, + workspaceId, + ); + + return fieldMetadataItems.map((fieldMetadataItem) => { + return fieldMetadataMorphRelationCollection.filter( + (fieldMetadataMorphRelation) => + fieldMetadataItem.name === + fieldMetadataMorphRelation.sourceFieldMetadata.name, + ); + }); + }); + } + private createIndexMetadataLoader() { return new DataLoader( async (dataLoaderParams: IndexMetadataLoaderPayload[]) => { @@ -161,7 +211,7 @@ export class DataloaderService { return []; } - return Object.values(objectMetadata.fieldsById).map( + const fields = Object.values(objectMetadata.fieldsById).map( // TODO: fix this as we should merge FieldMetadataEntity and FieldMetadataInterface (fieldMetadata) => { const overridesFieldToCompute = [ @@ -195,6 +245,8 @@ export class DataloaderService { }; }, ); + + return filterMorphRelationDuplicateFieldsDTO(fields); }); return fieldMetadataCollection; diff --git a/packages/twenty-server/src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util.ts b/packages/twenty-server/src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util.ts new file mode 100644 index 000000000..f9b151433 --- /dev/null +++ b/packages/twenty-server/src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util.ts @@ -0,0 +1,13 @@ +import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; + +export const filterMorphRelationDuplicateFieldsDTO = ( + fields: FieldMetadataDTO[], +) => { + return fields.filter((currentField) => { + return !fields.some( + (otherField) => + otherField.name === currentField.name && + otherField.id > currentField.id, + ); + }); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index 83dadc7d9..cecba278c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -112,6 +112,7 @@ import { FieldMetadataService } from './services/field-metadata.service'; exports: [ FieldMetadataService, FieldMetadataRelationService, + FieldMetadataMorphRelationService, FieldMetadataRelatedRecordsService, FieldMetadataEnumValidationService, FieldMetadataValidationService, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index 6964cbc13..b9f553598 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/graphql'; import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; @@ -40,6 +41,7 @@ import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { isMorphRelationFieldMetadataType } from 'src/engine/utils/is-morph-relation-field-metadata-type.util'; import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; @UseGuards(WorkspaceAuthGuard) @@ -168,4 +170,43 @@ export class FieldMetadataResolver { fieldMetadataGraphqlApiExceptionHandler(error); } } + + @ResolveField(() => [RelationDTO], { nullable: true }) + async morphRelations( + @AuthWorkspace() workspace: Workspace, + @Parent() + fieldMetadata: FieldMetadataEntity, + @Context() context: { loaders: IDataloaders }, + ): Promise { + if (!isMorphRelationFieldMetadataType(fieldMetadata.type)) { + return null; + } + + try { + const morphRelations = await context.loaders.morphRelationLoader.load({ + fieldMetadata, + workspaceId: workspace.id, + }); + + // typescript issue, it's not possible to use the fieldMetadata.settings directly in morphRelations.map + const settings = fieldMetadata.settings; + + if (!isDefined(settings) || !isDefined(settings.relationType)) { + throw new FieldMetadataException( + `Morph relation settings ${isDefined(settings) && 'relationType'} are required`, + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + return morphRelations.map((morphRelation) => ({ + type: settings.relationType, + sourceObjectMetadata: morphRelation.sourceObjectMetadata, + targetObjectMetadata: morphRelation.targetObjectMetadata, + sourceFieldMetadata: morphRelation.sourceFieldMetadata, + targetFieldMetadata: morphRelation.targetFieldMetadata, + })); + } catch (error) { + fieldMetadataGraphqlApiExceptionHandler(error); + } + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts index 920b618d1..a6fce0f5f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service.ts @@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; @@ -15,17 +16,21 @@ import { FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; -import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.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 { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; import { computeMorphRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-relation-field-join-column-name.util'; import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util'; +import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +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 { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps'; +import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @Injectable() export class FieldMetadataMorphRelationService { constructor( private readonly fieldMetadataRelationService: FieldMetadataRelationService, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, ) {} async createMorphRelationFieldMetadataItems({ @@ -153,4 +158,117 @@ export class FieldMetadataMorphRelationService { return fieldsCreated; } + + async findCachedFieldMetadataMorphRelation( + fieldMetadataItems: Array< + Pick< + FieldMetadataInterface, + | 'id' + | 'type' + | 'objectMetadataId' + | 'relationTargetFieldMetadataId' + | 'relationTargetObjectMetadataId' + | 'name' + > + >, + workspaceId: string, + ): Promise< + Array<{ + sourceObjectMetadata: ObjectMetadataEntity; + sourceFieldMetadata: FieldMetadataEntity; + targetObjectMetadata: ObjectMetadataEntity; + targetFieldMetadata: FieldMetadataEntity; + }> + > { + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( + workspaceId, + ); + + const fieldMetadataItemsAndMorphSiblings: Pick< + FieldMetadataInterface, + | 'id' + | 'type' + | 'objectMetadataId' + | 'relationTargetFieldMetadataId' + | 'relationTargetObjectMetadataId' + | 'name' + >[] = fieldMetadataItems.flatMap((fieldMetadataItem) => { + const fieldsById = + objectMetadataMaps.byId[fieldMetadataItem.objectMetadataId]?.fieldsById; + + if (!isDefined(fieldsById)) { + throw new FieldMetadataException( + `Fields by id not found for object metadata ${fieldMetadataItem.objectMetadataId}`, + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + return Object.values(fieldsById) + .filter( + (fieldMetadataById) => + fieldMetadataItem.name === fieldMetadataById.name, + ) + .map((fieldMetadataById) => { + return { + id: fieldMetadataById.id, + type: fieldMetadataById.type, + objectMetadataId: fieldMetadataById.objectMetadataId, + relationTargetFieldMetadataId: + fieldMetadataById.relationTargetFieldMetadataId, + relationTargetObjectMetadataId: + fieldMetadataById.relationTargetObjectMetadataId, + name: fieldMetadataById.name, + }; + }); + }); + + return fieldMetadataItemsAndMorphSiblings.map((fieldMetadataItem) => { + const { + id, + objectMetadataId, + relationTargetFieldMetadataId, + relationTargetObjectMetadataId, + } = fieldMetadataItem; + + if (!relationTargetObjectMetadataId || !relationTargetFieldMetadataId) { + throw new FieldMetadataException( + `Relation target object metadata id or relation target field metadata id not found for field metadata ${id}`, + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + const sourceObjectMetadata = objectMetadataMaps.byId[objectMetadataId]; + const targetObjectMetadata = + objectMetadataMaps.byId[relationTargetObjectMetadataId]; + const sourceFieldMetadata = sourceObjectMetadata?.fieldsById[id]; + const targetFieldMetadata = + targetObjectMetadata?.fieldsById[relationTargetFieldMetadataId]; + + if ( + !sourceObjectMetadata || + !targetObjectMetadata || + !sourceFieldMetadata || + !targetFieldMetadata + ) { + throw new FieldMetadataException( + `Field relation metadata not found for field metadata ${id}`, + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + return { + sourceObjectMetadata: + getObjectMetadataFromObjectMetadataItemWithFieldMaps( + sourceObjectMetadata, + ) as ObjectMetadataEntity, + sourceFieldMetadata: sourceFieldMetadata as FieldMetadataEntity, + targetObjectMetadata: + getObjectMetadataFromObjectMetadataItemWithFieldMaps( + targetObjectMetadata, + ) as ObjectMetadataEntity, + targetFieldMetadata: targetFieldMetadata as FieldMetadataEntity, + }; + }); + } } diff --git a/packages/twenty-server/src/engine/utils/is-morph-relation-field-metadata-type.util.ts b/packages/twenty-server/src/engine/utils/is-morph-relation-field-metadata-type.util.ts new file mode 100644 index 000000000..031564ecd --- /dev/null +++ b/packages/twenty-server/src/engine/utils/is-morph-relation-field-metadata-type.util.ts @@ -0,0 +1,7 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +export const isMorphRelationFieldMetadataType = ( + type: FieldMetadataType, +): type is FieldMetadataType.MORPH_RELATION => { + return type === FieldMetadataType.MORPH_RELATION; +};