feat: new relation resolver (#9794)

Fix [#240](https://github.com/twentyhq/core-team-issues/issues/240)
This commit is contained in:
Jérémy M
2025-01-24 10:38:50 +01:00
committed by GitHub
parent 5783c41df2
commit 8d794374f1
13 changed files with 318 additions and 8 deletions

View File

@ -75,6 +75,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsNewRelationEnabled,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};

View File

@ -14,4 +14,5 @@ export enum FeatureFlagKey {
IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED',
IsLocalizationEnabled = 'IS_LOCALIZATION_ENABLED',
IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED',
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
}

View File

@ -1,6 +1,11 @@
import DataLoader from 'dataloader';
import { RelationMetadataLoaderPayload } from 'src/engine/dataloaders/dataloader.service';
import {
RelationLoaderPayload,
RelationMetadataLoaderPayload,
} from 'src/engine/dataloaders/dataloader.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
export interface IDataloaders {
@ -8,4 +13,14 @@ export interface IDataloaders {
RelationMetadataLoaderPayload,
RelationMetadataEntity
>;
relationLoader: DataLoader<
RelationLoaderPayload,
{
sourceObjectMetadata: ObjectMetadataEntity;
targetObjectMetadata: ObjectMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
targetFieldMetadata: FieldMetadataEntity;
}
>;
}

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { DataloaderService } from 'src/engine/dataloaders/dataloader.service';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { RelationMetadataModule } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.module';
@Module({
imports: [RelationMetadataModule, FieldMetadataModule],
providers: [DataloaderService],
imports: [RelationMetadataModule],
exports: [DataloaderService],
})
export class DataloaderModule {}

View File

@ -5,6 +5,9 @@ import DataLoader from 'dataloader';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { RelationMetadataService } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.service';
@ -16,14 +19,37 @@ export type RelationMetadataLoaderPayload = {
>;
};
export type RelationLoaderPayload = {
workspaceId: string;
fieldMetadata: Pick<
FieldMetadataInterface,
| 'type'
| 'id'
| 'objectMetadataId'
| 'relationTargetFieldMetadataId'
| 'relationTargetObjectMetadataId'
>;
};
@Injectable()
export class DataloaderService {
constructor(
private readonly relationMetadataService: RelationMetadataService,
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
) {}
createLoaders(): IDataloaders {
const relationMetadataLoader = new DataLoader<
const relationMetadataLoader = this.createRelationMetadataLoader();
const relationLoader = this.createRelationLoader();
return {
relationMetadataLoader,
relationLoader,
};
}
private createRelationMetadataLoader() {
return new DataLoader<
RelationMetadataLoaderPayload,
RelationMetadataEntity
>(async (dataLoaderParams: RelationMetadataLoaderPayload[]) => {
@ -40,9 +66,30 @@ export class DataloaderService {
return relationsMetadataCollection;
});
}
return {
relationMetadataLoader,
};
private createRelationLoader() {
return new DataLoader<
RelationLoaderPayload,
{
sourceObjectMetadata: ObjectMetadataEntity;
targetObjectMetadata: ObjectMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
targetFieldMetadata: FieldMetadataEntity;
}
>(async (dataLoaderParams: RelationLoaderPayload[]) => {
const workspaceId = dataLoaderParams[0].workspaceId;
const fieldMetadataItems = dataLoaderParams.map(
(dataLoaderParam) => dataLoaderParam.fieldMetadata,
);
const fieldMetadataRelationCollection =
await this.fieldMetadataRelationService.findCachedFieldMetadataRelation(
fieldMetadataItems,
workspaceId,
);
return fieldMetadataRelationCollection;
});
}
}

View File

@ -0,0 +1,38 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IsEnum, IsNotEmpty } from 'class-validator';
import { Relation } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
registerEnumType(RelationType, {
name: 'RelationType',
description: 'Relation type',
});
@ObjectType('Relation')
export class RelationDTO {
@IsEnum(RelationType)
@IsNotEmpty()
@Field(() => RelationType)
type: RelationType;
@IsNotEmpty()
@Field(() => ObjectMetadataDTO)
sourceObjectMetadata: Relation<ObjectMetadataDTO>;
@IsNotEmpty()
@Field(() => ObjectMetadataDTO)
targetObjectMetadata: Relation<ObjectMetadataDTO>;
@IsNotEmpty()
@Field(() => FieldMetadataDTO)
sourceFieldMetadata: Relation<FieldMetadataDTO>;
@IsNotEmpty()
@Field(() => FieldMetadataDTO)
targetFieldMetadata: Relation<FieldMetadataDTO>;
}

View File

@ -14,4 +14,6 @@ export enum FieldMetadataExceptionCode {
FIELD_ALREADY_EXISTS = 'FIELD_ALREADY_EXISTS',
OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
FIELD_METADATA_RELATION_NOT_ENABLED = 'FIELD_METADATA_RELATION_NOT_ENABLED',
FIELD_METADATA_RELATION_MALFORMED = 'FIELD_METADATA_RELATION_MALFORMED',
}

View File

@ -9,12 +9,14 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
@ -22,6 +24,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { ViewModule } from 'src/modules/view/view.module';
@ -42,6 +45,8 @@ import { UpdateFieldInput } from './dtos/update-field.input';
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
WorkspaceMetadataVersionModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
ObjectMetadataModule,
DataSourceModule,
TypeORMModule,
@ -86,9 +91,14 @@ import { UpdateFieldInput } from './dtos/update-field.input';
IsFieldMetadataDefaultValue,
IsFieldMetadataOptions,
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataResolver,
],
exports: [FieldMetadataService, FieldMetadataRelatedRecordsService],
exports: [
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataRelatedRecordsService,
],
})
export class FieldMetadataModule {}

View File

@ -14,6 +14,8 @@ import {
import { FieldMetadataType } from 'twenty-shared';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@ -22,14 +24,24 @@ import { CreateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-m
import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { RelationDefinitionDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation-definition.dto';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { UpdateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
@UseGuards(WorkspaceAuthGuard)
@Resolver(() => FieldMetadataDTO)
export class FieldMetadataResolver {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
constructor(
private readonly fieldMetadataService: FieldMetadataService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Mutation(() => FieldMetadataDTO)
async createOneField(
@ -127,4 +139,56 @@ export class FieldMetadataResolver {
fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@ResolveField(() => RelationDTO, { nullable: true })
async relation(
@AuthWorkspace() workspace: Workspace,
@Parent() fieldMetadata: FieldMetadataEntity<FieldMetadataType.RELATION>,
@Context() context: { loaders: IDataloaders },
): Promise<RelationDTO | null | undefined> {
if (!isRelationFieldMetadataType(fieldMetadata.type)) {
return null;
}
try {
const isNewRelationEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsNewRelationEnabled,
workspace.id,
);
if (!isNewRelationEnabled) {
throw new FieldMetadataException(
'New relation feature is not enabled for this workspace',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_NOT_ENABLED,
);
}
const {
sourceObjectMetadata,
targetObjectMetadata,
sourceFieldMetadata,
targetFieldMetadata,
} = await context.loaders.relationLoader.load({
fieldMetadata,
workspaceId: workspace.id,
});
if (!fieldMetadata.settings) {
throw new FieldMetadataException(
'Relation settings are required',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
return {
type: fieldMetadata.settings.relationType,
sourceObjectMetadata,
targetObjectMetadata,
sourceFieldMetadata,
targetFieldMetadata,
};
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(error);
}
}
}

View File

@ -4,6 +4,8 @@ import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-met
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
export interface FieldMetadataInterface<
@ -23,6 +25,10 @@ export interface FieldMetadataInterface<
isUnique?: boolean;
fromRelationMetadata?: RelationMetadataEntity;
toRelationMetadata?: RelationMetadataEntity;
relationTargetFieldMetadataId?: string;
relationTargetFieldMetadata?: FieldMetadataEntity;
relationTargetObjectMetadataId?: string;
relationTargetObjectMetadata?: ObjectMetadataEntity;
isCustom?: boolean;
generatedType?: 'STORED' | 'VIRTUAL';
asExpression?: string;

View File

@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { cleanObjectMetadata } from 'src/engine/metadata-modules/utils/clean-object-metadata.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class FieldMetadataRelationService {
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findCachedFieldMetadataRelation(
fieldMetadataItems: Array<
Pick<
FieldMetadataInterface,
| 'id'
| 'type'
| 'objectMetadataId'
| 'relationTargetFieldMetadataId'
| 'relationTargetObjectMetadataId'
>
>,
workspaceId: string,
): Promise<
Array<{
sourceObjectMetadata: ObjectMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
targetObjectMetadata: ObjectMetadataEntity;
targetFieldMetadata: FieldMetadataEntity;
}>
> {
const metadataVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspaceId);
if (!metadataVersion) {
throw new FieldMetadataException(
`Metadata version not found for workspace ${workspaceId}`,
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspaceId,
metadataVersion,
);
if (!objectMetadataMaps) {
throw new FieldMetadataException(
`Object metadata map not found for workspace ${workspaceId} and metadata version ${metadataVersion}`,
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return fieldMetadataItems.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: cleanObjectMetadata(
sourceObjectMetadata,
) as ObjectMetadataEntity,
sourceFieldMetadata: sourceFieldMetadata as FieldMetadataEntity,
targetObjectMetadata: cleanObjectMetadata(
targetObjectMetadata,
) as ObjectMetadataEntity,
targetFieldMetadata: targetFieldMetadata as FieldMetadataEntity,
};
});
}
}

View File

@ -23,6 +23,8 @@ export const fieldMetadataGraphqlApiExceptionHandler = (error: Error) => {
throw new ConflictError(error.message);
case FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND:
case FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR:
case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_NOT_ENABLED:
case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED:
default:
throw new InternalServerError(error.message);
}

View File

@ -0,0 +1,10 @@
import omit from 'lodash.omit';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const cleanObjectMetadata = (
objectMetadata: ObjectMetadataItemWithFieldMaps,
): ObjectMetadataInterface =>
omit(objectMetadata, ['fieldsById', 'fieldsByName']);