fieldmetadatatype + featurelfag creation (#13021)

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-07-08 12:23:28 +02:00
committed by GitHub
parent 56607c0449
commit a5deddaffd
47 changed files with 1447 additions and 793 deletions

View File

@ -463,6 +463,7 @@ export type CreateFieldInput = {
isSystem?: InputMaybe<Scalars['Boolean']>;
isUnique?: InputMaybe<Scalars['Boolean']>;
label: Scalars['String'];
morphRelationsCreationPayload?: InputMaybe<Array<Scalars['JSON']>>;
name: Scalars['String'];
objectMetadataId: Scalars['String'];
options?: InputMaybe<Scalars['JSON']>;
@ -679,6 +680,7 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
@ -747,6 +749,7 @@ export enum FieldMetadataType {
EMAILS = 'EMAILS',
FULL_NAME = 'FULL_NAME',
LINKS = 'LINKS',
MORPH_RELATION = 'MORPH_RELATION',
MULTI_SELECT = 'MULTI_SELECT',
NUMBER = 'NUMBER',
NUMERIC = 'NUMERIC',

View File

@ -459,6 +459,7 @@ export type CreateFieldInput = {
isSystem?: InputMaybe<Scalars['Boolean']>;
isUnique?: InputMaybe<Scalars['Boolean']>;
label: Scalars['String'];
morphRelationsCreationPayload?: InputMaybe<Array<Scalars['JSON']>>;
name: Scalars['String'];
objectMetadataId: Scalars['String'];
options?: InputMaybe<Scalars['JSON']>;
@ -643,6 +644,7 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
@ -711,6 +713,7 @@ export enum FieldMetadataType {
EMAILS = 'EMAILS',
FULL_NAME = 'FULL_NAME',
LINKS = 'LINKS',
MORPH_RELATION = 'MORPH_RELATION',
MULTI_SELECT = 'MULTI_SELECT',
NUMBER = 'NUMBER',
NUMERIC = 'NUMERIC',

View File

@ -56,7 +56,8 @@ export const generateEmptyFieldValue = ({
case FieldMetadataType.BOOLEAN: {
return true;
}
case FieldMetadataType.RELATION: {
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION: {
if (fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE) {
return null;
}

View File

@ -122,6 +122,11 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel
Icon: IllustrationIconOneToMany,
category: 'Relation',
} as const satisfies SettingsFieldTypeConfig<FieldRelationValue<any>>,
[FieldMetadataType.MORPH_RELATION]: {
label: 'Morph Relation',
Icon: IllustrationIconOneToMany,
category: 'Relation',
} as const satisfies SettingsFieldTypeConfig<FieldRelationValue<any>>,
[FieldMetadataType.RATING]: {
label: 'Rating',
Icon: IllustrationIconStar,

View File

@ -11,6 +11,7 @@ import { FieldType } from '@/settings/data-model/types/FieldType';
import { SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { SettingsPath } from '@/types/SettingsPath';
import { TextInput } from '@/ui/input/components/TextInput';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@ -20,6 +21,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import { H2Title, IconSearch } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FeatureFlagKey } from '~/generated/graphql';
import { SettingsDataModelFieldTypeFormValues } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldSelect';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
@ -102,7 +104,9 @@ export const SettingsObjectNewFieldSelector = ({
break;
}
};
const isMorphRelationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
);
return (
<>
{' '}
@ -131,6 +135,12 @@ export const SettingsObjectNewFieldSelector = ({
<StyledContainer>
{fieldTypeConfigs
.filter(([, config]) => config.category === category)
.filter(([key]) => {
return (
key !== FieldMetadataType.MORPH_RELATION ||
isMorphRelationEnabled
);
})
.map(([key, config]) => (
<StyledCardContainer key={key}>
<UndecoratedLink

View File

@ -12,6 +12,7 @@ export const DEFAULT_ICONS_BY_FIELD_TYPE: Record<FieldMetadataType, string> = {
[FieldMetadataType.RATING]: 'IconStar',
[FieldMetadataType.RAW_JSON]: 'IconBraces',
[FieldMetadataType.RELATION]: 'IconRelationOneToMany',
[FieldMetadataType.MORPH_RELATION]: 'IconRelationOneToMany',
[FieldMetadataType.SELECT]: 'IconTag',
[FieldMetadataType.TEXT]: 'IconTypography',
[FieldMetadataType.UUID]: 'IconId',

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MorphIndexUpdate1751558024634 implements MigrationInterface {
name = 'MorphIndexUpdate1751558024634';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."fieldMetadata" DROP CONSTRAINT "IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_FIELD_METADATA_NAME_OBJMID_WORKSPACE_ID_EXCEPT_MORPH_UNIQUE" ON "core"."fieldMetadata" ("name", "objectMetadataId", "workspaceId") WHERE "type" <> 'MORPH_RELATION'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "core"."IDX_FIELD_METADATA_NAME_OBJMID_WORKSPACE_ID_EXCEPT_MORPH_UNIQUE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."fieldMetadata" ADD CONSTRAINT "IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE" UNIQUE ("name", "objectMetadataId", "workspaceId")`,
);
}
}

View File

@ -112,24 +112,29 @@ export class ExtendObjectTypeDefinitionV2Factory {
for (const fieldMetadata of objectMetadata.fields) {
// Ignore non-relation fields as they are already defined
if (
!isFieldMetadataInterfaceOfType(
const isRelation =
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.RELATION,
)
) {
) ||
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.MORPH_RELATION,
);
if (!isRelation) {
continue;
}
if (!fieldMetadata.settings) {
throw new Error(
`Field Metadata of type RELATION with id ${fieldMetadata.id} has no settings`,
`Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no settings`,
);
}
if (!fieldMetadata.relationTargetObjectMetadataId) {
throw new Error(
`Field Metadata of type RELATION with id ${fieldMetadata.id} has no relation target object metadata id`,
`Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no relation target object metadata id`,
);
}

View File

@ -19,17 +19,19 @@ export class RelationTypeV2Factory {
) {}
public create(
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>,
fieldMetadata: FieldMetadataInterface<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>,
): GraphQLOutputType {
if (!fieldMetadata.settings) {
throw new Error(
`Field Metadata of type RELATION with id ${fieldMetadata.id} has no settings`,
`Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no settings`,
);
}
if (!fieldMetadata.relationTargetObjectMetadataId) {
throw new Error(
`Field Metadata of type RELATION with id ${fieldMetadata.id} has no relation target object metadata id`,
`Field Metadata of type RELATION or MORPH_RELATION with id ${fieldMetadata.id} has no relation target object metadata id`,
);
}

View File

@ -15,7 +15,10 @@ import {
} from 'graphql';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import {
FieldMetadataSettings,
NumberDataType,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum';
import {
@ -62,7 +65,11 @@ export class TypeMapperService {
settings?: FieldMetadataSettings<FieldMetadataType>,
isIdField?: boolean,
): GraphQLScalarType | undefined {
if (isIdField || fieldMetadataType === FieldMetadataType.RELATION) {
if (
isIdField ||
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
) {
return GraphQLID;
}
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
@ -75,7 +82,7 @@ export class TypeMapperService {
FieldMetadataType.NUMBER,
getNumberScalarType(
(settings as FieldMetadataSettings<FieldMetadataType.NUMBER>)
?.dataType,
?.dataType ?? NumberDataType.FLOAT,
),
],
[FieldMetadataType.NUMERIC, BigFloatScalarType],
@ -97,7 +104,11 @@ export class TypeMapperService {
settings?: FieldMetadataSettings<FieldMetadataType>,
isIdField?: boolean,
): GraphQLInputObjectType | GraphQLScalarType | undefined {
if (isIdField || fieldMetadataType === FieldMetadataType.RELATION) {
if (
isIdField ||
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
) {
return UUIDFilterType;
}
@ -137,6 +148,7 @@ export class TypeMapperService {
const typeOrderByMapping = new Map<FieldMetadataType, GraphQLEnumType>([
[FieldMetadataType.UUID, OrderByDirectionType],
[FieldMetadataType.RELATION, OrderByDirectionType],
[FieldMetadataType.MORPH_RELATION, OrderByDirectionType],
[FieldMetadataType.TEXT, OrderByDirectionType],
[FieldMetadataType.DATE_TIME, OrderByDirectionType],
[FieldMetadataType.DATE, OrderByDirectionType],

View File

@ -64,9 +64,17 @@ export const generateFields = <
for (const fieldMetadata of objectMetadata.fields) {
let generatedField;
if (
isFieldMetadataInterfaceOfType(fieldMetadata, FieldMetadataType.RELATION)
) {
const isRelation =
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.RELATION,
) ||
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.MORPH_RELATION,
);
if (isRelation) {
generatedField = generateRelationField({
fieldMetadata,
kind,
@ -162,7 +170,9 @@ const generateRelationField = <
typeFactory,
isRelationConnectEnabled,
}: {
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>;
fieldMetadata: FieldMetadataInterface<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>;
kind: T;
options: WorkspaceBuildSchemaOptions;
typeFactory: TypeFactory<T>;

View File

@ -130,6 +130,12 @@ describe('mapFieldMetadataToGraphqlQuery', () => {
} as FieldMetadataDefaultSettings;
}
if (fieldMetadataType === FieldMetadataType.MORPH_RELATION) {
field.settings = {
relationType: RelationType.MANY_TO_ONE,
} as FieldMetadataDefaultSettings;
}
expect(
mapFieldMetadataToGraphqlQuery(objectMetadataMapsMock, field),
).toBeDefined();

View File

@ -38,11 +38,15 @@ export const mapFieldMetadataToGraphqlQuery = (
FieldMetadataType.TS_VECTOR,
].includes(fieldType);
const isRelation =
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) ||
isFieldMetadataInterfaceOfType(field, FieldMetadataType.MORPH_RELATION);
if (fieldIsSimpleValue) {
return field.name;
} else if (
maxDepthForRelations > 0 &&
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) &&
isRelation &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
const targetObjectMetadataId = field.relationTargetObjectMetadataId;
@ -69,7 +73,7 @@ export const mapFieldMetadataToGraphqlQuery = (
}`;
} else if (
maxDepthForRelations > 0 &&
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) &&
isRelation &&
field.settings?.relationType === RelationType.ONE_TO_MANY
) {
const targetObjectMetadataId = field.relationTargetObjectMetadataId;

View File

@ -6,6 +6,7 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
}

View File

@ -5,7 +5,7 @@ import { capitalize } from 'twenty-shared/utils';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils';
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.util';
import {
computeDepthParameters,
computeEndingBeforeParameters,

View File

@ -1,7 +1,7 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { faker } from '@faker-js/faker';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
@ -88,7 +88,8 @@ export const generateRandomFieldValue = ({
return isDefined(field.options[0].value) ? [field.options[0].value] : [];
}
case FieldMetadataType.RELATION: {
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION: {
return null;
}

View File

@ -11,8 +11,8 @@ import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metada
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
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 { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { resolveOverridableString } from 'src/engine/metadata-modules/field-metadata/utils/resolve-overridable-string.util';
import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@ -59,7 +59,6 @@ export type IndexFieldMetadataLoaderPayload = {
export class DataloaderService {
constructor(
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
) {}
@ -166,7 +165,7 @@ export class DataloaderService {
>(
(acc, field) => ({
...acc,
[field]: this.fieldMetadataService.resolveOverridableString(
[field]: resolveOverridableString(
fieldMetadata,
field,
dataLoaderParams[0].locale,

View File

@ -8,6 +8,12 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
export type RelationCreationPayload = {
targetObjectMetadataId: string;
targetFieldLabel: string;
targetFieldIcon: string;
type: RelationType;
};
@InputType()
export class CreateFieldInput extends OmitType(
FieldMetadataDTO,
@ -25,12 +31,11 @@ export class CreateFieldInput extends OmitType(
// TODO @prastoin implement validation for this with validate nested and dedicated class instance
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
relationCreationPayload?: {
targetObjectMetadataId: string;
targetFieldLabel: string;
targetFieldIcon: string;
type: RelationType;
};
relationCreationPayload?: RelationCreationPayload;
@IsOptional()
@Field(() => [GraphQLJSON], { nullable: true })
morphRelationsCreationPayload?: RelationCreationPayload[];
}
@InputType()

View File

@ -1,148 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ClassConstructor, plainToInstance } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
ValidationError,
validateOrReject,
} from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
enum ValueType {
PERCENTAGE = 'percentage',
NUMBER = 'number',
SHORT_NUMBER = 'shortNumber',
}
class NumberSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
decimals?: number;
@IsOptional()
@IsEnum(ValueType)
type?: 'percentage' | 'number' | 'shortNumber';
}
class TextSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
displayedMaxRows?: number;
}
export class RelationCreationPayloadValidation {
@IsUUID()
targetObjectMetadataId?: string;
@IsString()
targetFieldLabel: string;
@IsString()
targetFieldIcon: string;
@IsEnum(RelationType)
type: RelationType;
}
@Injectable()
export class FieldMetadataValidationService<
T extends FieldMetadataType = FieldMetadataType,
> {
constructor() {}
async validateRelationCreationPayloadOrThrow(
relationCreationPayload: RelationCreationPayloadValidation,
) {
try {
const relationCreationPayloadInstance = plainToInstance(
RelationCreationPayloadValidation,
relationCreationPayload,
);
await validateOrReject(relationCreationPayloadInstance);
} catch (error) {
const errorMessages = Array.isArray(error)
? error
.map((err: ValidationError) => Object.values(err.constraints ?? {}))
.flat()
.join(', ')
: error.message;
throw new FieldMetadataException(
`Relation creation payload is invalid: ${errorMessages}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
async validateSettingsOrThrow({
fieldType,
settings,
}: {
fieldType: FieldMetadataType;
settings: FieldMetadataSettings<T>;
}) {
switch (fieldType) {
case FieldMetadataType.NUMBER:
await this.validateSettings<FieldMetadataType.NUMBER>(
NumberSettingsValidation,
settings,
);
break;
case FieldMetadataType.TEXT:
await this.validateSettings<FieldMetadataType.TEXT>(
TextSettingsValidation,
settings,
);
break;
default:
break;
}
}
private async validateSettings<Type extends FieldMetadataType>(
validator: ClassConstructor<
Type extends FieldMetadataType.NUMBER
? NumberSettingsValidation
: Type extends FieldMetadataType.TEXT
? TextSettingsValidation
: never
>,
settings: FieldMetadataSettings<T>,
) {
try {
const settingsInstance = plainToInstance(validator, settings);
await validateOrReject(settingsInstance);
} catch (error) {
const errorMessages = Array.isArray(error)
? error
.map((err: ValidationError) => Object.values(err.constraints ?? {}))
.flat()
.join(', ')
: error.message;
throw new FieldMetadataException(
`Value for settings is invalid: ${errorMessages}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
}

View File

@ -10,7 +10,6 @@ import {
OneToOne,
PrimaryGeneratedColumn,
Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
@ -23,11 +22,15 @@ import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-meta
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Entity('fieldMetadata')
@Unique('IDX_FIELD_METADATA_NAME_OBJECT_METADATA_ID_WORKSPACE_ID_UNIQUE', [
'name',
'objectMetadataId',
'workspaceId',
])
// max length of index is 63 characters
@Index(
'IDX_FIELD_METADATA_NAME_OBJMID_WORKSPACE_ID_EXCEPT_MORPH_UNIQUE',
['name', 'objectMetadataId', 'workspaceId'],
{
unique: true,
where: `"type" <> ''MORPH_RELATION''`,
},
)
@Index('IDX_FIELD_METADATA_RELATION_TARGET_FIELD_METADATA_ID', [
'relationTargetFieldMetadataId',
])

View File

@ -9,16 +9,18 @@ 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 { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook';
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 { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.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';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@ -32,10 +34,10 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor
import { ViewModule } from 'src/modules/view/view.module';
import { FieldMetadataEntity } from './field-metadata.entity';
import { FieldMetadataService } from './field-metadata.service';
import { CreateFieldInput } from './dtos/create-field.input';
import { UpdateFieldInput } from './dtos/update-field.input';
import { FieldMetadataService } from './services/field-metadata.service';
@Module({
imports: [
@ -53,6 +55,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
DataSourceModule,
TypeORMModule,
ActorModule,
FeatureFlagModule,
ViewModule,
PermissionsModule,
WorkspaceMetadataCacheModule,
@ -61,6 +64,8 @@ import { UpdateFieldInput } from './dtos/update-field.input';
IsFieldMetadataDefaultValue,
FieldMetadataService,
FieldMetadataRelatedRecordsService,
FieldMetadataMorphRelationService,
FieldMetadataRelationService,
FieldMetadataValidationService,
FieldMetadataEnumValidationService,
],
@ -98,6 +103,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataMorphRelationService,
FieldMetadataValidationService,
FieldMetadataEnumValidationService,
FieldMetadataResolver,

View File

@ -35,8 +35,8 @@ 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 { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
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';

View File

@ -9,8 +9,8 @@ import {
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
jest.mock('@lingui/core', () => ({
i18n: {

View File

@ -16,7 +16,7 @@ import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMe
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
interface StandardFieldUpdate extends Partial<UpdateFieldInput> {
standardOverrides?: FieldStandardOverridesDTO;

View File

@ -22,7 +22,7 @@ export enum DateDisplayFormat {
export type FieldNumberVariant = 'number' | 'percentage';
export type FieldMetadataNumberSettings = {
dataType: NumberDataType;
dataType?: NumberDataType;
decimals?: number;
type?: FieldNumberVariant;
};

View File

@ -1,93 +0,0 @@
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 { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps';
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 objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow(
workspaceId,
);
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:
getObjectMetadataFromObjectMetadataItemWithFieldMaps(
sourceObjectMetadata,
) as ObjectMetadataEntity,
sourceFieldMetadata: sourceFieldMetadata as FieldMetadataEntity,
targetObjectMetadata:
getObjectMetadataFromObjectMetadataItemWithFieldMaps(
targetObjectMetadata,
) as ObjectMetadataEntity,
targetFieldMetadata: targetFieldMetadata as FieldMetadataEntity,
};
});
}
}

View File

@ -0,0 +1,138 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import omit from 'lodash.omit';
import { FieldMetadataType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
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';
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 { 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';
@Injectable()
export class FieldMetadataMorphRelationService {
constructor(
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
) {}
async createMorphRelationFieldMetadataItems({
fieldMetadataForCreate,
morphRelationsCreationPayload,
objectMetadata,
fieldMetadataRepository,
objectMetadataMaps,
}: {
fieldMetadataForCreate: CreateFieldInput;
morphRelationsCreationPayload: CreateFieldInput['morphRelationsCreationPayload'];
objectMetadata: ObjectMetadataItemWithFieldMaps;
fieldMetadataRepository: Repository<FieldMetadataEntity>;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<FieldMetadataEntity[]> {
if (
!isDefined(morphRelationsCreationPayload) ||
!Array.isArray(morphRelationsCreationPayload)
) {
throw new FieldMetadataException(
'Morph relations creation payload is not defined',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
if (morphRelationsCreationPayload.length < 1) {
throw new FieldMetadataException(
'Morph relations creation payload must not be empty',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
const fieldsCreated: FieldMetadataEntity[] = [];
for (const relation of morphRelationsCreationPayload) {
const relationFieldMetadataForCreate =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
{
fieldMetadataInput: fieldMetadataForCreate,
relationCreationPayload: relation,
objectMetadata,
},
);
await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics(
{
fieldMetadataInput: relationFieldMetadataForCreate,
fieldMetadataType: relationFieldMetadataForCreate.type,
objectMetadataMaps,
},
);
const createdFieldMetadataItem = await fieldMetadataRepository.save(
omit(relationFieldMetadataForCreate, 'id'),
);
const targetFieldMetadataName = computeMetadataNameFromLabel(
relation.targetFieldLabel,
);
const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation(
{
objectMetadataId: relation.targetObjectMetadataId,
type: FieldMetadataType.RELATION,
name: targetFieldMetadataName,
label: relation.targetFieldLabel,
icon: relation.targetFieldIcon,
workspaceId: fieldMetadataForCreate.workspaceId,
settings: fieldMetadataForCreate.settings,
},
);
const targetFieldMetadataToCreateWithRelation =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
{
fieldMetadataInput: targetFieldMetadataToCreate,
relationCreationPayload: {
targetObjectMetadataId: objectMetadata.id,
targetFieldLabel: fieldMetadataForCreate.label,
targetFieldIcon: fieldMetadataForCreate.icon ?? 'Icon123',
type:
relation.type === RelationType.ONE_TO_MANY
? RelationType.MANY_TO_ONE
: RelationType.ONE_TO_MANY,
},
objectMetadata,
},
);
// todo better type
const targetFieldMetadataToCreateWithRelationWithId = {
id: v4(),
...targetFieldMetadataToCreateWithRelation,
};
const targetFieldMetadata = await fieldMetadataRepository.save({
...targetFieldMetadataToCreateWithRelationWithId,
relationTargetFieldMetadataId: createdFieldMetadataItem.id,
});
const createdFieldMetadataItemUpdated =
await fieldMetadataRepository.save({
...createdFieldMetadataItem,
relationTargetFieldMetadataId: targetFieldMetadata.id,
});
fieldsCreated.push(createdFieldMetadataItemUpdated, targetFieldMetadata);
}
return fieldsCreated;
}
}

View File

@ -1,19 +1,23 @@
import { Injectable } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { settings } from 'src/engine/constants/settings';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.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 { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
import { SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
@ -310,4 +314,74 @@ export class FieldMetadataRelatedRecordsService {
private getMaxPosition(viewGroups: ViewGroupWorkspaceEntity[]): number {
return viewGroups.reduce((max, group) => Math.max(max, group.position), 0);
}
async createViewAndViewFields(
createdFieldMetadatas: FieldMetadataEntity[],
workspaceId: string,
) {
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId,
});
await workspaceDataSource.transaction(
async (workspaceEntityManager: WorkspaceEntityManager) => {
const viewsRepository = workspaceEntityManager.getRepository('view', {
shouldBypassPermissionChecks: true,
});
const viewFieldsRepository = workspaceEntityManager.getRepository(
'viewField',
{
shouldBypassPermissionChecks: true,
},
);
for (const createdFieldMetadata of createdFieldMetadatas) {
const views = await viewsRepository.find({
where: {
objectMetadataId: createdFieldMetadata.objectMetadataId,
},
});
if (!isEmpty(views)) {
const view = views[0];
const existingViewFields = await viewFieldsRepository.find({
where: {
viewId: view.id,
},
});
const isVisible =
existingViewFields.length < settings.maxVisibleViewFields;
const createdFieldIsAlreadyInView = existingViewFields.some(
(existingViewField) =>
existingViewField.fieldMetadataId === createdFieldMetadata.id,
);
if (!createdFieldIsAlreadyInView) {
const lastPosition = existingViewFields
.map((viewField) => viewField.position)
.reduce((acc, position) => {
if (position > acc) {
return position;
}
return acc;
}, -1);
await viewFieldsRepository.insert({
fieldMetadataId: createdFieldMetadata.id,
position: lastPosition + 1,
isVisible,
size: 180,
viewId: view.id,
});
}
}
}
},
);
}
}

View File

@ -0,0 +1,330 @@
import { Injectable, ValidationError } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, 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';
import { UpdateFieldInput } 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 { 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 { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
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 { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
export class RelationCreationPayloadValidation {
@IsUUID()
targetObjectMetadataId?: string;
@IsString()
targetFieldLabel: string;
@IsString()
targetFieldIcon: string;
@IsEnum(RelationType)
type: RelationType;
}
type ValidateFieldMetadataArgs<T extends UpdateFieldInput | CreateFieldInput> =
{
fieldMetadataType: FieldMetadataType;
fieldMetadataInput: T;
objectMetadata: ObjectMetadataItemWithFieldMaps;
existingFieldMetadata?: FieldMetadataInterface;
objectMetadataMaps: ObjectMetadataMaps;
};
@Injectable()
export class FieldMetadataRelationService {
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async createRelationFieldMetadataItems({
fieldMetadataInput,
objectMetadata,
fieldMetadataRepository,
}: {
fieldMetadataInput: CreateFieldInput;
objectMetadata: ObjectMetadataItemWithFieldMaps;
fieldMetadataRepository: Repository<FieldMetadataEntity>;
}): Promise<FieldMetadataEntity[]> {
const createdFieldMetadataItem =
await fieldMetadataRepository.save(fieldMetadataInput);
const relationCreationPayload = fieldMetadataInput.relationCreationPayload;
if (!isDefined(relationCreationPayload)) {
throw new FieldMetadataException(
'Relation creation payload is not defined',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
const targetFieldMetadataName = computeMetadataNameFromLabel(
relationCreationPayload.targetFieldLabel,
);
const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation({
objectMetadataId: relationCreationPayload.targetObjectMetadataId,
type: fieldMetadataInput.type,
name: targetFieldMetadataName,
label: relationCreationPayload.targetFieldLabel,
icon: relationCreationPayload.targetFieldIcon,
workspaceId: fieldMetadataInput.workspaceId,
});
const targetFieldMetadataToCreateWithRelation =
await this.addCustomRelationFieldMetadataForCreation({
fieldMetadataInput: targetFieldMetadataToCreate,
relationCreationPayload: {
targetObjectMetadataId: objectMetadata.id,
targetFieldLabel: fieldMetadataInput.label,
targetFieldIcon: fieldMetadataInput.icon ?? 'Icon123',
type:
relationCreationPayload.type === RelationType.ONE_TO_MANY
? RelationType.MANY_TO_ONE
: RelationType.ONE_TO_MANY,
},
objectMetadata,
});
// todo better type
const targetFieldMetadataToCreateWithRelationWithId = {
id: v4(),
...targetFieldMetadataToCreateWithRelation,
};
const targetFieldMetadata = await fieldMetadataRepository.save({
...targetFieldMetadataToCreateWithRelationWithId,
relationTargetFieldMetadataId: createdFieldMetadataItem.id,
});
const createdFieldMetadataItemUpdated = await fieldMetadataRepository.save({
...createdFieldMetadataItem,
relationTargetFieldMetadataId: targetFieldMetadata.id,
});
return [createdFieldMetadataItemUpdated, targetFieldMetadata];
}
async validateFieldMetadataRelationSpecifics<
T extends UpdateFieldInput | CreateFieldInput,
>({
fieldMetadataInput,
fieldMetadataType,
objectMetadataMaps,
}: Pick<
ValidateFieldMetadataArgs<T>,
'fieldMetadataInput' | 'fieldMetadataType' | 'objectMetadataMaps'
>): Promise<T> {
// TODO: clean typings, we should try to validate both update and create inputs in the same function
const isRelation =
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION;
if (
isRelation &&
isDefined(
(fieldMetadataInput as unknown as CreateFieldInput)
.relationCreationPayload,
)
) {
const relationCreationPayload = (
fieldMetadataInput as unknown as CreateFieldInput
).relationCreationPayload;
if (isDefined(relationCreationPayload)) {
await this.validateRelationCreationPayloadOrThrow(
relationCreationPayload,
);
const computedMetadataNameFromLabel = computeMetadataNameFromLabel(
relationCreationPayload.targetFieldLabel,
);
validateMetadataNameOrThrow(computedMetadataNameFromLabel);
const objectMetadataTarget =
objectMetadataMaps.byId[
relationCreationPayload.targetObjectMetadataId
];
validateFieldNameAvailabilityOrThrow(
computedMetadataNameFromLabel,
objectMetadataTarget,
);
}
}
return fieldMetadataInput;
}
private async validateRelationCreationPayloadOrThrow(
relationCreationPayload: RelationCreationPayloadValidation,
) {
try {
const relationCreationPayloadInstance = plainToInstance(
RelationCreationPayloadValidation,
relationCreationPayload,
);
await validateOrReject(relationCreationPayloadInstance);
} catch (error) {
const errorMessages = Array.isArray(error)
? error
.map((err: ValidationError) => Object.values(err.constraints ?? {}))
.flat()
.join(', ')
: error.message;
throw new FieldMetadataException(
`Relation creation payload is invalid: ${errorMessages}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
async findCachedFieldMetadataRelation(
fieldMetadataItems: Array<
Pick<
FieldMetadataInterface,
| 'id'
| 'type'
| 'objectMetadataId'
| 'relationTargetFieldMetadataId'
| 'relationTargetObjectMetadataId'
>
>,
workspaceId: string,
): Promise<
Array<{
sourceObjectMetadata: ObjectMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
targetObjectMetadata: ObjectMetadataEntity;
targetFieldMetadata: FieldMetadataEntity;
}>
> {
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow(
workspaceId,
);
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:
getObjectMetadataFromObjectMetadataItemWithFieldMaps(
sourceObjectMetadata,
) as ObjectMetadataEntity,
sourceFieldMetadata: sourceFieldMetadata as FieldMetadataEntity,
targetObjectMetadata:
getObjectMetadataFromObjectMetadataItemWithFieldMaps(
targetObjectMetadata,
) as ObjectMetadataEntity,
targetFieldMetadata: targetFieldMetadata as FieldMetadataEntity,
};
});
}
addCustomRelationFieldMetadataForCreation({
fieldMetadataInput,
relationCreationPayload,
objectMetadata,
}: {
fieldMetadataInput: CreateFieldInput;
relationCreationPayload: CreateFieldInput['relationCreationPayload'];
objectMetadata: ObjectMetadataItemWithFieldMaps;
}) {
const isRelation =
isFieldMetadataInterfaceOfType(
fieldMetadataInput,
FieldMetadataType.RELATION,
) ||
isFieldMetadataInterfaceOfType(
fieldMetadataInput,
FieldMetadataType.MORPH_RELATION,
);
const isManyToOne =
isRelation && relationCreationPayload?.type === RelationType.MANY_TO_ONE;
const isOneToMany =
isRelation && relationCreationPayload?.type === RelationType.ONE_TO_MANY;
const defaultIcon = 'IconRelationOneToMany';
const joinColumnName = `${fieldMetadataInput.name}${capitalize(objectMetadata.nameSingular)}Id`;
return {
...fieldMetadataInput,
icon: fieldMetadataInput.icon ?? defaultIcon,
relationCreationPayload,
relationTargetObjectMetadataId:
relationCreationPayload?.targetObjectMetadataId,
settings: {
...fieldMetadataInput.settings,
...(isOneToMany
? {
relationType: RelationType.ONE_TO_MANY,
}
: {}),
...(isManyToOne
? {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName,
}
: {}),
},
};
}
}

View File

@ -0,0 +1,191 @@
import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { ClassConstructor, plainToInstance } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
Max,
Min,
ValidationError,
isDefined,
validateOrReject,
} from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
type ValidateFieldMetadataArgs = {
fieldMetadataType: FieldMetadataType;
fieldMetadataInput: CreateFieldInput | UpdateFieldInput;
objectMetadata: ObjectMetadataItemWithFieldMaps;
existingFieldMetadata?: FieldMetadataInterface;
};
enum ValueType {
PERCENTAGE = 'percentage',
NUMBER = 'number',
SHORT_NUMBER = 'shortNumber',
}
class NumberSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
decimals?: number;
@IsOptional()
@IsEnum(ValueType)
type?: 'percentage' | 'number' | 'shortNumber';
}
class TextSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
displayedMaxRows?: number;
}
@Injectable()
export class FieldMetadataValidationService {
constructor(
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
) {}
async validateSettingsOrThrow<T extends FieldMetadataType>({
fieldType,
settings,
}: {
fieldType: FieldMetadataType;
settings: FieldMetadataSettings<T>;
}) {
switch (fieldType) {
case FieldMetadataType.NUMBER:
await this.validateSettings<FieldMetadataType.NUMBER>(
NumberSettingsValidation,
settings,
);
break;
case FieldMetadataType.TEXT:
await this.validateSettings<FieldMetadataType.TEXT>(
TextSettingsValidation,
settings,
);
break;
default:
break;
}
}
private async validateSettings<Type extends FieldMetadataType>(
validator: ClassConstructor<
Type extends FieldMetadataType.NUMBER
? NumberSettingsValidation
: Type extends FieldMetadataType.TEXT
? TextSettingsValidation
: never
>,
settings: FieldMetadataSettings<Type>,
) {
try {
const settingsInstance = plainToInstance(validator, settings);
await validateOrReject(settingsInstance);
} catch (error) {
const errorMessages = Array.isArray(error)
? error
.map((err: ValidationError) => Object.values(err.constraints ?? {}))
.flat()
.join(', ')
: error.message;
throw new FieldMetadataException(
`Value for settings is invalid: ${errorMessages}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
async validateFieldMetadata({
fieldMetadataInput,
fieldMetadataType,
objectMetadata,
existingFieldMetadata,
}: ValidateFieldMetadataArgs): Promise<void> {
if (fieldMetadataInput.name) {
try {
validateMetadataNameOrThrow(fieldMetadataInput.name);
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new FieldMetadataException(
error.message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
throw error;
}
try {
validateFieldNameAvailabilityOrThrow(
fieldMetadataInput.name,
objectMetadata,
);
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new FieldMetadataException(
`Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`Name is not available, it may be duplicating another field's name.`,
},
);
}
throw error;
}
}
if (fieldMetadataInput.isNullable === false) {
if (!isDefined(fieldMetadataInput.defaultValue)) {
throw new FieldMetadataException(
'Default value is required for non nullable fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
if (isEnumFieldMetadataType(fieldMetadataType)) {
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
{
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
},
);
}
if (fieldMetadataInput.settings) {
await this.validateSettingsOrThrow({
fieldType: fieldMetadataType,
settings: fieldMetadataInput.settings,
});
}
}
}

View File

@ -1,58 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import isEmpty from 'lodash.isempty';
import { APP_LOCALES } from 'twenty-shared/translations';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, FindOneOptions, In, Repository } from 'typeorm';
import { v4 as uuidV4, 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 { settings } from 'src/engine/constants/settings';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
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 { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
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 { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { UpdateFieldInput } 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 { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service';
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
import { buildUpdatableStandardFieldInput } from 'src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util';
import { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw';
import {
computeColumnName,
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
import { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util';
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
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 { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import {
computeMetadataNameFromLabel,
validateNameAndLabelAreSyncOrThrow,
} from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
@ -64,28 +52,11 @@ import {
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { ViewService } from 'src/modules/view/services/view.service';
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties';
import { FieldMetadataValidationService } from './field-metadata-validation.service';
import { FieldMetadataEntity } from './field-metadata.entity';
import { generateDefaultValue } from './utils/generate-default-value';
import { generateRatingOptions } from './utils/generate-rating-optionts.util';
type ValidateFieldMetadataArgs<T extends UpdateFieldInput | CreateFieldInput> =
{
fieldMetadataType: FieldMetadataType;
fieldMetadataInput: T;
objectMetadata: ObjectMetadataItemWithFieldMaps;
existingFieldMetadata?: FieldMetadataInterface;
objectMetadataMaps: ObjectMetadataMaps;
};
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
@ -97,13 +68,15 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
private readonly viewService: ViewService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly featureFlagService: FeatureFlagService,
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService,
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
) {
super(fieldMetadataRepository);
}
@ -191,14 +164,14 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
const updatableFieldInput =
existingFieldMetadata.isCustom === false
? this.buildUpdatableStandardFieldInput(
? buildUpdatableStandardFieldInput(
fieldMetadataInput,
existingFieldMetadata,
)
: fieldMetadataInput;
const optionsForUpdate = isDefined(fieldMetadataInput.options)
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
? prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
: undefined;
const defaultValueForUpdate =
updatableFieldInput.defaultValue !== undefined
@ -211,12 +184,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
...optionsForUpdate,
};
await this.validateFieldMetadata({
await this.fieldMetadataValidationService.validateFieldMetadata({
fieldMetadataType: existingFieldMetadata.type,
existingFieldMetadata,
fieldMetadataInput: fieldMetadataForUpdate,
objectMetadata: objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const isLabelSyncedWithName =
@ -508,248 +480,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
return fieldMetadata;
}
private buildUpdatableStandardFieldInput(
fieldMetadataInput: UpdateFieldInput,
existingFieldMetadata: Pick<
FieldMetadataInterface,
'type' | 'isNullable' | 'defaultValue' | 'options'
>,
) {
const updatableStandardFieldInput: UpdateFieldInput & {
standardOverrides?: FieldStandardOverridesDTO;
} = {
id: fieldMetadataInput.id,
isActive: fieldMetadataInput.isActive,
workspaceId: fieldMetadataInput.workspaceId,
defaultValue: fieldMetadataInput.defaultValue,
settings: fieldMetadataInput.settings,
isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName,
};
if ('standardOverrides' in fieldMetadataInput) {
updatableStandardFieldInput.standardOverrides =
fieldMetadataInput.standardOverrides as FieldStandardOverridesDTO;
}
if (
existingFieldMetadata.type === FieldMetadataType.SELECT ||
existingFieldMetadata.type === FieldMetadataType.MULTI_SELECT
) {
return {
...updatableStandardFieldInput,
options: fieldMetadataInput.options,
};
}
return updatableStandardFieldInput;
}
private async validateFieldMetadata<
T extends UpdateFieldInput | CreateFieldInput,
>({
fieldMetadataInput,
fieldMetadataType,
objectMetadata,
existingFieldMetadata,
objectMetadataMaps,
}: ValidateFieldMetadataArgs<T>): Promise<T> {
if (fieldMetadataInput.name) {
try {
validateMetadataNameOrThrow(fieldMetadataInput.name);
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new FieldMetadataException(
error.message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
throw error;
}
try {
validateFieldNameAvailabilityOrThrow(
fieldMetadataInput.name,
objectMetadata,
);
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new FieldMetadataException(
`Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`Name is not available, it may be duplicating another field's name.`,
},
);
}
throw error;
}
}
if (fieldMetadataInput.isNullable === false) {
if (!isDefined(fieldMetadataInput.defaultValue)) {
throw new FieldMetadataException(
'Default value is required for non nullable fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
if (isEnumFieldMetadataType(fieldMetadataType)) {
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
{
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
},
);
}
if (fieldMetadataInput.settings) {
await this.fieldMetadataValidationService.validateSettingsOrThrow({
fieldType: fieldMetadataType,
settings: fieldMetadataInput.settings,
});
}
// TODO: clean typings, we should try to validate both update and create inputs in the same function
if (
fieldMetadataType === FieldMetadataType.RELATION &&
isDefined(
(fieldMetadataInput as unknown as CreateFieldInput)
.relationCreationPayload,
)
) {
const relationCreationPayload = (
fieldMetadataInput as unknown as CreateFieldInput
).relationCreationPayload;
if (isDefined(relationCreationPayload)) {
await this.fieldMetadataValidationService.validateRelationCreationPayloadOrThrow(
relationCreationPayload,
);
const computedMetadataNameFromLabel = computeMetadataNameFromLabel(
relationCreationPayload.targetFieldLabel,
);
validateMetadataNameOrThrow(computedMetadataNameFromLabel);
const objectMetadataTarget =
objectMetadataMaps.byId[
relationCreationPayload.targetObjectMetadataId
];
validateFieldNameAvailabilityOrThrow(
computedMetadataNameFromLabel,
objectMetadataTarget,
);
}
}
return fieldMetadataInput;
}
resolveOverridableString(
fieldMetadata: Pick<
FieldMetadataDTO,
'label' | 'description' | 'icon' | 'isCustom' | 'standardOverrides'
>,
labelKey: 'label' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined,
): string {
if (fieldMetadata.isCustom) {
return fieldMetadata[labelKey] ?? '';
}
const translationValue =
// @ts-expect-error legacy noImplicitAny
fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey];
if (isDefined(translationValue)) {
return translationValue;
}
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
const translatedMessage = i18n._(messageId);
if (translatedMessage === messageId) {
return fieldMetadata[labelKey] ?? '';
}
return translatedMessage;
}
private prepareCustomFieldMetadataOptions(
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
): undefined | Pick<FieldMetadataEntity, 'options'> {
return {
options: options.map((option) => ({
id: uuidV4(),
...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
option,
['label', 'value', 'id'],
),
})),
};
}
private prepareCustomFieldMetadataForCreation(
fieldMetadataInput: CreateFieldInput,
) {
const options = fieldMetadataInput.options
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
: undefined;
const defaultValue =
fieldMetadataInput.defaultValue ??
generateDefaultValue(fieldMetadataInput.type);
const relationCreationPayload = fieldMetadataInput.relationCreationPayload;
return {
id: v4(),
createdAt: new Date(),
updatedAt: new Date(),
name: fieldMetadataInput.name,
label: fieldMetadataInput.label,
icon: fieldMetadataInput.icon,
type: fieldMetadataInput.type,
isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName,
objectMetadataId: fieldMetadataInput.objectMetadataId,
workspaceId: fieldMetadataInput.workspaceId,
isNullable: generateNullable(
fieldMetadataInput.type,
fieldMetadataInput.isNullable,
fieldMetadataInput.isRemoteCreation,
),
relationTargetObjectMetadataId:
relationCreationPayload?.targetObjectMetadataId,
defaultValue,
...options,
isActive: true,
isCustom: true,
settings: {
...fieldMetadataInput.settings,
...(fieldMetadataInput.type === FieldMetadataType.RELATION &&
relationCreationPayload &&
relationCreationPayload.type === RelationType.ONE_TO_MANY
? {
relationType: RelationType.ONE_TO_MANY,
}
: {}),
...(fieldMetadataInput.type === FieldMetadataType.RELATION &&
relationCreationPayload &&
relationCreationPayload.type === RelationType.MANY_TO_ONE
? {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName: `${fieldMetadataInput.name}Id`,
}
: {}),
},
};
}
private groupFieldInputsByObjectId(
fieldMetadataInputs: CreateFieldInput[],
): Record<string, CreateFieldInput[]> {
@ -766,137 +496,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
);
}
private async validateAndCreateFieldMetadataItems(
fieldMetadataInput: CreateFieldInput,
objectMetadata: ObjectMetadataItemWithFieldMaps,
fieldMetadataRepository: Repository<FieldMetadataEntity>,
objectMetadataMaps: ObjectMetadataMaps,
): Promise<FieldMetadataEntity[]> {
if (!fieldMetadataInput.isRemoteCreation) {
assertMutationNotOnRemoteObject(objectMetadata);
}
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
fieldMetadataInput.options = generateRatingOptions();
}
const fieldMetadataForCreate =
this.prepareCustomFieldMetadataForCreation(fieldMetadataInput);
await this.validateFieldMetadata({
fieldMetadataType: fieldMetadataForCreate.type,
fieldMetadataInput: {
...fieldMetadataForCreate,
relationCreationPayload: fieldMetadataInput.relationCreationPayload,
},
objectMetadata,
objectMetadataMaps,
});
if (fieldMetadataForCreate.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow(
fieldMetadataForCreate.label,
fieldMetadataForCreate.name,
);
}
const createdFieldMetadataItem = await fieldMetadataRepository.save(
fieldMetadataForCreate,
);
if (fieldMetadataForCreate.type !== FieldMetadataType.RELATION) {
return [createdFieldMetadataItem];
}
const relationCreationPayload = fieldMetadataInput.relationCreationPayload;
if (!isDefined(relationCreationPayload)) {
throw new FieldMetadataException(
'Relation creation payload is not defined',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
const targetFieldMetadataName = computeMetadataNameFromLabel(
relationCreationPayload.targetFieldLabel,
);
const targetFieldMetadataToCreate =
this.prepareCustomFieldMetadataForCreation({
objectMetadataId: relationCreationPayload.targetObjectMetadataId,
type: FieldMetadataType.RELATION,
name: targetFieldMetadataName,
label: relationCreationPayload.targetFieldLabel,
icon: relationCreationPayload.targetFieldIcon,
workspaceId: fieldMetadataForCreate.workspaceId,
relationCreationPayload: {
targetObjectMetadataId: objectMetadata.id,
targetFieldLabel: fieldMetadataInput.label,
targetFieldIcon: fieldMetadataInput.icon ?? 'Icon123',
type:
relationCreationPayload.type === RelationType.ONE_TO_MANY
? RelationType.MANY_TO_ONE
: RelationType.ONE_TO_MANY,
},
});
const targetFieldMetadata = await fieldMetadataRepository.save({
...targetFieldMetadataToCreate,
relationTargetFieldMetadataId: createdFieldMetadataItem.id,
});
const createdFieldMetadataItemUpdated = await fieldMetadataRepository.save({
...createdFieldMetadataItem,
relationTargetFieldMetadataId: targetFieldMetadata.id,
});
return [createdFieldMetadataItemUpdated, targetFieldMetadata];
}
private async createMigrationActions({
createdFieldMetadataItems,
objectMetadataMap,
isRemoteCreation,
}: {
createdFieldMetadataItems: FieldMetadataEntity[];
objectMetadataMap: Record<string, ObjectMetadataItemWithFieldMaps>;
isRemoteCreation: boolean;
}): Promise<WorkspaceMigrationTableAction[]> {
if (isRemoteCreation) {
return [];
}
const migrationActions: WorkspaceMigrationTableAction[] = [];
for (const createdFieldMetadata of createdFieldMetadataItems) {
if (
isFieldMetadataEntityOfType(
createdFieldMetadata,
FieldMetadataType.RELATION,
)
) {
const relationType = createdFieldMetadata.settings?.relationType;
if (relationType === RelationType.ONE_TO_MANY) {
continue;
}
}
migrationActions.push({
name: computeObjectTargetTable(
objectMetadataMap[createdFieldMetadata.objectMetadataId],
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
createdFieldMetadata,
),
});
}
return migrationActions;
}
async createMany(
fieldMetadataInputs: CreateFieldInput[],
): Promise<FieldMetadataEntity[]> {
@ -910,6 +509,25 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
);
const workspaceId = fieldMetadataInputs[0].workspaceId;
const isMorphRelationEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
workspaceId,
);
const isSomeFieldMetadatInputsMorph = fieldMetadataInputs.some(
(fieldMetadataInput) =>
fieldMetadataInput.type === FieldMetadataType.MORPH_RELATION,
);
if (isSomeFieldMetadatInputsMorph && !isMorphRelationEnabled) {
throw new FieldMetadataException(
'Morph Relation feature is not enabled for this workspace',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
@ -977,7 +595,10 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
await queryRunner.commitTransaction();
await this.createViewAndViewFields(createdFieldMetadatas, workspaceId);
await this.fieldMetadataRelatedRecordsService.createViewAndViewFields(
createdFieldMetadatas,
workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
@ -995,89 +616,128 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
}
}
private async createViewAndViewFields(
createdFieldMetadatas: FieldMetadataEntity[],
workspaceId: string,
) {
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId,
});
private async validateAndCreateFieldMetadataItems(
fieldMetadataInput: CreateFieldInput,
objectMetadata: ObjectMetadataItemWithFieldMaps,
fieldMetadataRepository: Repository<FieldMetadataEntity>,
objectMetadataMaps: ObjectMetadataMaps,
): Promise<FieldMetadataEntity[]> {
if (!fieldMetadataInput.isRemoteCreation) {
assertMutationNotOnRemoteObject(objectMetadata);
}
await workspaceDataSource.transaction(
async (workspaceEntityManager: WorkspaceEntityManager) => {
const viewsRepository = workspaceEntityManager.getRepository('view', {
shouldBypassPermissionChecks: true,
});
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
fieldMetadataInput.options = generateRatingOptions();
}
const viewFieldsRepository = workspaceEntityManager.getRepository(
'viewField',
if (fieldMetadataInput.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow(
fieldMetadataInput.label,
fieldMetadataInput.name,
);
}
const fieldMetadataForCreate =
prepareCustomFieldMetadataForCreation(fieldMetadataInput);
await this.fieldMetadataValidationService.validateFieldMetadata({
fieldMetadataType: fieldMetadataForCreate.type,
fieldMetadataInput: fieldMetadataForCreate,
objectMetadata,
});
const isRelation =
fieldMetadataInput.type === FieldMetadataType.RELATION ||
fieldMetadataInput.type === FieldMetadataType.MORPH_RELATION;
if (!isRelation) {
const createdFieldMetadataItem = await fieldMetadataRepository.save(
fieldMetadataForCreate,
);
return [createdFieldMetadataItem];
}
if (fieldMetadataInput.type === FieldMetadataType.RELATION) {
const relationFieldMetadataForCreate =
await this.fieldMetadataRelationService.addCustomRelationFieldMetadataForCreation(
{
shouldBypassPermissionChecks: true,
fieldMetadataInput: fieldMetadataForCreate,
relationCreationPayload: fieldMetadataInput.relationCreationPayload,
objectMetadata,
},
);
for (const createdFieldMetadata of createdFieldMetadatas) {
const views = await viewsRepository.find({
where: {
objectMetadataId: createdFieldMetadata.objectMetadataId,
},
});
await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics(
{
fieldMetadataInput: relationFieldMetadataForCreate,
fieldMetadataType: fieldMetadataForCreate.type,
objectMetadataMaps,
},
);
if (!isEmpty(views)) {
const view = views[0];
const existingViewFields = await viewFieldsRepository.find({
where: {
viewId: view.id,
},
});
return await this.fieldMetadataRelationService.createRelationFieldMetadataItems(
{
fieldMetadataInput: relationFieldMetadataForCreate,
objectMetadata,
fieldMetadataRepository,
},
);
}
const isVisible =
existingViewFields.length < settings.maxVisibleViewFields;
const createdFieldIsAlreadyInView = existingViewFields.some(
(existingViewField) =>
existingViewField.fieldMetadataId === createdFieldMetadata.id,
);
if (!createdFieldIsAlreadyInView) {
const lastPosition = existingViewFields
.map((viewField) => viewField.position)
.reduce((acc, position) => {
if (position > acc) {
return position;
}
return acc;
}, -1);
await viewFieldsRepository.insert({
fieldMetadataId: createdFieldMetadata.id,
position: lastPosition + 1,
isVisible,
size: 180,
viewId: view.id,
});
}
}
}
return await this.fieldMetadataMorphRelationService.createMorphRelationFieldMetadataItems(
{
fieldMetadataForCreate,
morphRelationsCreationPayload:
fieldMetadataInput.morphRelationsCreationPayload,
objectMetadata,
fieldMetadataRepository,
objectMetadataMaps,
},
);
}
async getFieldMetadataItemsByBatch(
objectMetadataIds: string[],
workspaceId: string,
) {
const fieldMetadataItems = await this.fieldMetadataRepository.find({
where: { objectMetadataId: In(objectMetadataIds), workspaceId },
});
private async createMigrationActions({
createdFieldMetadataItems,
objectMetadataMap,
isRemoteCreation,
}: {
createdFieldMetadataItems: FieldMetadataEntity[];
objectMetadataMap: Record<string, ObjectMetadataItemWithFieldMaps>;
isRemoteCreation: boolean;
}): Promise<WorkspaceMigrationTableAction[]> {
if (isRemoteCreation) {
return [];
}
return objectMetadataIds.map((objectMetadataId) =>
fieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.objectMetadataId === objectMetadataId,
),
);
const migrationActions: WorkspaceMigrationTableAction[] = [];
for (const createdFieldMetadata of createdFieldMetadataItems) {
if (
isFieldMetadataEntityOfType(
createdFieldMetadata,
FieldMetadataType.RELATION,
)
) {
const relationType = createdFieldMetadata.settings?.relationType;
if (relationType === RelationType.ONE_TO_MANY) {
continue;
}
}
migrationActions.push({
name: computeObjectTargetTable(
objectMetadataMap[createdFieldMetadata.objectMetadataId],
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
createdFieldMetadata,
),
});
}
return migrationActions;
}
}

View File

@ -1,15 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service';
describe('FieldMetadataValidationService', () => {
let service: FieldMetadataValidationService;
beforeAll(() => {
service = new FieldMetadataValidationService();
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FieldMetadataValidationService,
{
provide: FieldMetadataEnumValidationService,
useValue: {},
},
],
}).compile();
service = module.get<FieldMetadataValidationService>(
FieldMetadataValidationService,
);
});
it('should validate NUMBER settings successfully', async () => {

View File

@ -0,0 +1,42 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
export const buildUpdatableStandardFieldInput = (
fieldMetadataInput: UpdateFieldInput,
existingFieldMetadata: Pick<
FieldMetadataInterface,
'type' | 'isNullable' | 'defaultValue' | 'options'
>,
) => {
const updatableStandardFieldInput: UpdateFieldInput & {
standardOverrides?: FieldStandardOverridesDTO;
} = {
id: fieldMetadataInput.id,
isActive: fieldMetadataInput.isActive,
workspaceId: fieldMetadataInput.workspaceId,
defaultValue: fieldMetadataInput.defaultValue,
settings: fieldMetadataInput.settings,
isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName,
};
if ('standardOverrides' in fieldMetadataInput) {
updatableStandardFieldInput.standardOverrides =
fieldMetadataInput.standardOverrides as FieldStandardOverridesDTO;
}
if (
existingFieldMetadata.type === FieldMetadataType.SELECT ||
existingFieldMetadata.type === FieldMetadataType.MULTI_SELECT
) {
return {
...updatableStandardFieldInput,
options: fieldMetadataInput.options,
};
}
return updatableStandardFieldInput;
};

View File

@ -0,0 +1,23 @@
import { v4 } from 'uuid';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties';
export const prepareCustomFieldMetadataOptions = (
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
): undefined | Pick<FieldMetadataEntity, 'options'> => {
return {
options: options.map((option) => ({
id: v4(),
...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(option, [
'label',
'value',
'id',
]),
})),
};
};

View File

@ -0,0 +1,42 @@
import { v4 } from 'uuid';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util';
export const prepareCustomFieldMetadataForCreation = (
fieldMetadataInput: CreateFieldInput,
) => {
const options = fieldMetadataInput.options
? prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
: undefined;
const defaultValue =
fieldMetadataInput.defaultValue ??
generateDefaultValue(fieldMetadataInput.type);
return {
id: v4(),
createdAt: new Date(),
updatedAt: new Date(),
name: fieldMetadataInput.name,
label: fieldMetadataInput.label,
icon: fieldMetadataInput.icon,
type: fieldMetadataInput.type,
isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName,
objectMetadataId: fieldMetadataInput.objectMetadataId,
workspaceId: fieldMetadataInput.workspaceId,
isNullable: generateNullable(
fieldMetadataInput.type,
fieldMetadataInput.isNullable,
fieldMetadataInput.isRemoteCreation,
),
relationTargetObjectMetadataId:
fieldMetadataInput?.relationCreationPayload?.targetObjectMetadataId,
defaultValue,
...options,
isActive: true,
isCustom: true,
settings: fieldMetadataInput.settings,
};
};

View File

@ -0,0 +1,36 @@
import { i18n } from '@lingui/core';
import { isDefined } from 'class-validator';
import { APP_LOCALES } from 'twenty-shared/translations';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
export const resolveOverridableString = (
fieldMetadata: Pick<
FieldMetadataDTO,
'label' | 'description' | 'icon' | 'isCustom' | 'standardOverrides'
>,
labelKey: 'label' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined,
): string => {
if (fieldMetadata.isCustom) {
return fieldMetadata[labelKey] ?? '';
}
const translationValue =
// @ts-expect-error legacy noImplicitAny
fieldMetadata.standardOverrides?.translations?.[locale]?.[labelKey];
if (isDefined(translationValue)) {
return translationValue;
}
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
const translatedMessage = i18n._(messageId);
if (translatedMessage === messageId) {
return fieldMetadata[labelKey] ?? '';
}
return translatedMessage;
};

View File

@ -8,7 +8,7 @@ import { Repository } from 'typeorm';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import {

View File

@ -1,6 +1,7 @@
import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory';
import { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
import { MorphRelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/morph-relation-column-action.factory';
import { RelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/relation-column-action.factory';
import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
@ -10,4 +11,5 @@ export const workspaceColumnActionFactories = [
EnumColumnActionFactory,
CompositeColumnActionFactory,
RelationColumnActionFactory,
MorphRelationColumnActionFactory,
];

View File

@ -0,0 +1,32 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/workspace-migration/interfaces/workspace-column-action-options.interface';
import { ColumnActionAbstractFactory } from 'src/engine/metadata-modules/workspace-migration/factories/column-action-abstract.factory';
import {
WorkspaceMigrationColumnAlter,
WorkspaceMigrationColumnCreate,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
@Injectable()
export class MorphRelationColumnActionFactory extends ColumnActionAbstractFactory<FieldMetadataType.MORPH_RELATION> {
protected readonly logger = new Logger(MorphRelationColumnActionFactory.name);
protected handleCreateAction(
_fieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
_options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnCreate[] {
return [];
}
protected handleAlterAction(
_currentFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
_alteredFieldMetadata: FieldMetadataInterface<FieldMetadataType.MORPH_RELATION>,
_options?: WorkspaceColumnActionOptions,
): WorkspaceMigrationColumnAlter[] {
return [];
}
}

View File

@ -9,6 +9,7 @@ import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/worksp
import { BasicColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/basic-column-action.factory';
import { CompositeColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { EnumColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
import { MorphRelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/morph-relation-column-action.factory';
import { RelationColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/relation-column-action.factory';
import { TsVectorColumnActionFactory } from 'src/engine/metadata-modules/workspace-migration/factories/ts-vector-column-action.factory';
import {
@ -38,6 +39,7 @@ export class WorkspaceMigrationFactory {
private readonly enumColumnActionFactory: EnumColumnActionFactory,
private readonly compositeColumnActionFactory: CompositeColumnActionFactory,
private readonly relationColumnActionFactory: RelationColumnActionFactory,
private readonly morphRelationColumnActionFactory: MorphRelationColumnActionFactory,
) {
this.factoriesMap = new Map<
FieldMetadataType,
@ -106,6 +108,10 @@ export class WorkspaceMigrationFactory {
FieldMetadataType.RELATION,
{ factory: this.relationColumnActionFactory },
],
[
FieldMetadataType.MORPH_RELATION,
{ factory: this.morphRelationColumnActionFactory },
],
]);
}

View File

@ -38,12 +38,17 @@ export class EntitySchemaColumnFactory {
for (const fieldMetadata of fieldMetadataCollection) {
const key = fieldMetadata.name;
if (
const isRelation =
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.RELATION,
)
) {
) ||
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.MORPH_RELATION,
);
if (isRelation) {
const isManyToOneRelation =
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE;
const joinColumnName = fieldMetadata.settings?.joinColumnName;

View File

@ -27,12 +27,17 @@ export class EntitySchemaRelationFactory {
);
for (const fieldMetadata of fieldMetadataCollection) {
if (
!isFieldMetadataInterfaceOfType(
const isRelation =
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.RELATION,
)
) {
) ||
isFieldMetadataInterfaceOfType(
fieldMetadata,
FieldMetadataType.MORPH_RELATION,
);
if (!isRelation) {
continue;
}

View File

@ -18,7 +18,9 @@ interface RelationDetails {
}
export async function determineSchemaRelationDetails(
fieldMetadata: FieldMetadataInterface<FieldMetadataType.RELATION>,
fieldMetadata: FieldMetadataInterface<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>,
objectMetadataMaps: ObjectMetadataMaps,
): Promise<RelationDetails> {
if (!fieldMetadata.settings) {

View File

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

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import {
SEED_APPLE_WORKSPACE_ID,

View File

@ -1,8 +1,14 @@
import { CreateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { findManyFieldsMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('createOne', () => {
describe('FieldMetadataService name/label sync', () => {
let createdObjectMetadataId = '';
@ -107,4 +113,184 @@ describe('createOne', () => {
);
});
});
describe('FieldMetadataService relation fields', () => {
let createdObjectMetadataPersonId = '';
let createdObjectMetadataOpportunityId = '';
let createdObjectMetadataCompanyId = '';
beforeEach(async () => {
const {
data: {
createOneObject: { id: objectMetadataPersonId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForRelation',
namePlural: 'peopleForRelation',
labelSingular: 'Person For Relation',
labelPlural: 'People For Relation',
icon: 'IconPerson',
},
});
createdObjectMetadataPersonId = objectMetadataPersonId;
const {
data: {
createOneObject: { id: objectMetadataCompanyId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForRelation',
namePlural: 'companiesForRelation',
labelSingular: 'Company For Relation',
labelPlural: 'Companies For Relation',
icon: 'IconCompany',
},
});
createdObjectMetadataCompanyId = objectMetadataCompanyId;
const {
data: {
createOneObject: { id: objectMetadataOpportunityId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'opportunityForRelation',
namePlural: 'opportunitiesForRelation',
labelSingular: 'Opportunity For Relation',
labelPlural: 'Opportunities For Relation',
icon: 'IconOpportunity',
},
});
createdObjectMetadataOpportunityId = objectMetadataOpportunityId;
});
afterEach(async () => {
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataPersonId },
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataOpportunityId },
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataCompanyId },
});
});
it('should create a RELATION field type', async () => {
const createFieldInput: CreateOneFieldFactoryInput = {
name: 'person',
label: 'person field',
type: FieldMetadataType.RELATION,
objectMetadataId: createdObjectMetadataOpportunityId,
isLabelSyncedWithName: false,
relationCreationPayload: {
targetObjectMetadataId: createdObjectMetadataPersonId,
targetFieldLabel: 'opportunity',
targetFieldIcon: 'IconListOpportunity',
type: RelationType.MANY_TO_ONE,
},
};
const { data: createdFieldPerson } = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
expectToFail: false,
});
expect(createdFieldPerson.createOneField.name).toBe('person');
// TODO : find a way to filter by objectmetadataid toavoid loading all fieldMetadata objects
const findOpportunityOperation = findManyFieldsMetadataQueryFactory({
gqlFields: `
id
name
object {
id
nameSingular
}
relation {
type
}
settings
`,
input: {
filter: {},
paging: { first: 10000 },
},
});
const opportunityFieldsResponse = await makeMetadataAPIRequest(
findOpportunityOperation,
);
const allFields = opportunityFieldsResponse.body.data.fields.edges;
const opportunityFieldOnPerson = allFields.find(
(field: any) =>
field.node?.object?.id === createdObjectMetadataPersonId &&
field.node?.name ===
createFieldInput.relationCreationPayload?.targetFieldLabel,
).node;
expect(opportunityFieldOnPerson.object.nameSingular).toBe(
'personForRelation',
);
expect(opportunityFieldOnPerson.relation.type).toBe(
RelationType.ONE_TO_MANY,
);
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldPerson.createOneField.id },
});
});
// TODO: replace xit by it once the Morph works
xit('should create a MORPH_RELATION field type', async () => {
const createFieldInput: CreateOneFieldFactoryInput = {
name: 'owner',
label: 'owner field',
type: FieldMetadataType.MORPH_RELATION,
objectMetadataId: createdObjectMetadataOpportunityId,
isLabelSyncedWithName: false,
morphRelationsCreationPayload: [
{
targetObjectMetadataId: createdObjectMetadataPersonId,
targetFieldLabel: 'opportunity',
targetFieldIcon: 'IconListOpportunity',
type: RelationType.MANY_TO_ONE,
},
{
targetObjectMetadataId: createdObjectMetadataCompanyId,
targetFieldLabel: 'opportunity',
targetFieldIcon: 'IconListOpportunity',
type: RelationType.MANY_TO_ONE,
},
],
};
const { data: createdFieldOwner } = await createOneFieldMetadata({
input: createFieldInput,
gqlFields: `
id
name
label
isLabelSyncedWithName
`,
expectToFail: false,
});
// expect(createdFieldOwner.createOneField.name).toBe('owner');
await deleteOneFieldMetadata({
input: { idToDelete: createdFieldOwner.createOneField.id },
});
});
});
});

View File

@ -15,6 +15,7 @@ export enum FieldMetadataType {
SELECT = 'SELECT',
MULTI_SELECT = 'MULTI_SELECT',
RELATION = 'RELATION',
MORPH_RELATION = 'MORPH_RELATION',
POSITION = 'POSITION',
ADDRESS = 'ADDRESS',
RAW_JSON = 'RAW_JSON',