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 <charles@twenty.com>
This commit is contained in:
Guillim
2025-07-17 23:06:45 +02:00
committed by GitHub
parent 5b13f3096a
commit 6e5487ed76
9 changed files with 250 additions and 5 deletions

View File

@ -758,6 +758,7 @@ export type Field = {
isSystem?: Maybe<Scalars['Boolean']>;
isUnique?: Maybe<Scalars['Boolean']>;
label: Scalars['String'];
morphRelations?: Maybe<Array<Relation>>;
name: Scalars['String'];
object?: Maybe<Object>;
options?: Maybe<Scalars['JSON']>;

View File

@ -722,6 +722,7 @@ export type Field = {
isSystem?: Maybe<Scalars['Boolean']>;
isUnique?: Maybe<Scalars['Boolean']>;
label: Scalars['String'];
morphRelations?: Maybe<Array<Relation>>;
name: Scalars['String'];
object?: Maybe<Object>;
options?: Maybe<Scalars['JSON']>;

View File

@ -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[]

View File

@ -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<ObjectMetadataInterface, 'id'>;
@ -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<IndexMetadataLoaderPayload, IndexMetadataDTO[]>(
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;

View File

@ -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,
);
});
};

View File

@ -112,6 +112,7 @@ import { FieldMetadataService } from './services/field-metadata.service';
exports: [
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataMorphRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataEnumValidationService,
FieldMetadataValidationService,

View File

@ -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<FieldMetadataType.MORPH_RELATION>,
@Context() context: { loaders: IDataloaders },
): Promise<RelationDTO[] | null | undefined> {
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);
}
}
}

View File

@ -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,
};
});
}
}

View File

@ -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;
};