diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts index 8112b02d8c..f62d0a6427 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts @@ -3,7 +3,7 @@ import { type Manifest, type FieldManifest, } from 'twenty-shared/application'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate'; const validApplication: ApplicationManifest = { @@ -193,4 +193,171 @@ describe('manifestValidate', () => { expect(result.warnings).toContain('No front component defined'); }); }); + + describe('relation field validation', () => { + it('should fail when a RELATION field in fields is missing relationType', () => { + const relationFieldWithoutSettings = { + objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + universalIdentifier: '550e8400-e29b-41d4-a716-446655440010', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + relationTargetFieldMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440011', + relationTargetObjectMetadataUniversalIdentifier: + '20202020-b374-4779-a561-80086cb2e17f', + } as unknown as FieldManifest; + + const result = manifestValidate({ + ...validManifest, + fields: [relationFieldWithoutSettings], + }); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('company'); + expect(result.errors[0]).toContain('missing relationType'); + }); + + it('should fail when a RELATION field in object fields is missing relationType', () => { + const result = manifestValidate({ + ...validManifest, + objects: [ + { + universalIdentifier: 'obj-uuid', + nameSingular: 'recipient', + namePlural: 'recipients', + labelSingular: 'Recipient', + labelPlural: 'Recipients', + labelIdentifierFieldMetadataUniversalIdentifier: 'label-field-uuid', + fields: [ + { + universalIdentifier: 'label-field-uuid', + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + }, + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440012', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + relationTargetFieldMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440013', + relationTargetObjectMetadataUniversalIdentifier: + '20202020-b374-4779-a561-80086cb2e17f', + } as unknown as FieldManifest, + ], + }, + ], + }); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('company'); + expect(result.errors[0]).toContain('missing relationType'); + }); + + it('should pass when a RELATION field has valid universalSettings with relationType', () => { + const validRelationField = { + objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + universalIdentifier: '550e8400-e29b-41d4-a716-446655440014', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + relationTargetFieldMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440015', + relationTargetObjectMetadataUniversalIdentifier: + '20202020-b374-4779-a561-80086cb2e17f', + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'companyId', + }, + } as unknown as FieldManifest; + + const result = manifestValidate({ + ...validManifest, + fields: [validRelationField], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should fail when a MANY_TO_ONE field is missing joinColumnName', () => { + const manyToOneWithoutJoinColumn = { + objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + universalIdentifier: '550e8400-e29b-41d4-a716-446655440018', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + relationTargetFieldMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440019', + relationTargetObjectMetadataUniversalIdentifier: + '20202020-b374-4779-a561-80086cb2e17f', + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + }, + } as unknown as FieldManifest; + + const result = manifestValidate({ + ...validManifest, + fields: [manyToOneWithoutJoinColumn], + }); + + expect(result.isValid).toBe(false); + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('company'); + expect(result.errors[0]).toContain('missing joinColumnName'); + }); + + it('should pass when a ONE_TO_MANY field has no joinColumnName', () => { + const oneToManyField = { + objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + universalIdentifier: '550e8400-e29b-41d4-a716-446655440020', + type: FieldMetadataType.RELATION, + name: 'contacts', + label: 'Contacts', + relationTargetFieldMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440021', + relationTargetObjectMetadataUniversalIdentifier: + '20202020-b374-4779-a561-80086cb2e17f', + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, + } as unknown as FieldManifest; + + const result = manifestValidate({ + ...validManifest, + fields: [oneToManyField], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should fail when a RELATION field has an invalid relationType', () => { + const relationFieldWithBadType = { + objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + universalIdentifier: '550e8400-e29b-41d4-a716-446655440016', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + relationTargetFieldMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440017', + relationTargetObjectMetadataUniversalIdentifier: + '20202020-b374-4779-a561-80086cb2e17f', + universalSettings: { + relationType: 'INVALID_TYPE', + }, + } as unknown as FieldManifest; + + const result = manifestValidate({ + ...validManifest, + fields: [relationFieldWithBadType], + }); + + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('company'); + expect(result.errors[0]).toContain('invalid relationType'); + }); + }); }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts index 370bca7dae..d930c5ee43 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts @@ -1,6 +1,17 @@ -import { type Manifest } from 'twenty-shared/application'; +import { type FieldManifest, type Manifest } from 'twenty-shared/application'; +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { isNonEmptyArray } from 'twenty-shared/utils'; +const RELATION_FIELD_TYPES: string[] = [ + FieldMetadataType.RELATION, + FieldMetadataType.MORPH_RELATION, +]; + +const VALID_RELATION_TYPES: string[] = [ + RelationType.MANY_TO_ONE, + RelationType.ONE_TO_MANY, +]; + const extractDuplicates = (values: string[]): string[] => { const seen = new Set(); const duplicates = new Set(); @@ -34,6 +45,52 @@ const findUniversalIdentifiers = (obj: object): string[] => { return universalIdentifiers; }; + +const validateRelationFields = ( + fields: Pick[], +): string[] => { + const errors: string[] = []; + + for (const field of fields) { + if (!RELATION_FIELD_TYPES.includes(field.type)) { + continue; + } + + const settings = field.universalSettings as + | { relationType?: string; joinColumnName?: string | null } + | null + | undefined; + + if (!settings?.relationType) { + errors.push( + `Relation field "${field.name}" is missing relationType. ` + + `${field.type} fields must declare a relationType (${VALID_RELATION_TYPES.join(' or ')}) in universalSettings.`, + ); + continue; + } + + if (!VALID_RELATION_TYPES.includes(settings.relationType)) { + errors.push( + `Relation field "${field.name}" has invalid relationType "${settings.relationType}". ` + + `Expected ${VALID_RELATION_TYPES.join(' or ')}.`, + ); + continue; + } + + if ( + settings.relationType === RelationType.MANY_TO_ONE && + !settings.joinColumnName + ) { + errors.push( + `MANY_TO_ONE relation field "${field.name}" is missing joinColumnName. ` + + `MANY_TO_ONE relations must declare a joinColumnName in universalSettings.`, + ); + } + } + + return errors; +}; + export const manifestValidate = (manifest: Manifest) => { const errors: string[] = []; const warnings: string[] = []; @@ -56,5 +113,15 @@ export const manifestValidate = (manifest: Manifest) => { warnings.push('No front component defined'); } + const allFields: Pick< + FieldManifest, + 'type' | 'name' | 'universalSettings' + >[] = [ + ...manifest.fields, + ...manifest.objects.flatMap((object) => object.fields), + ]; + + errors.push(...validateRelationFields(allFields)); + return { errors, warnings, isValid: errors.length === 0 }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts index db95f7a287..38b5c8d573 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts @@ -34,6 +34,10 @@ export const validateJunctionTargetSettings = ({ }: ValidateJunctionTargetSettingsArgs): FlatFieldMetadataValidationError[] => { const { universalSettings } = universalFlatFieldMetadata; + if (!isDefined(universalSettings)) { + return []; + } + const junctionTargetFieldUniversalIdentifier = universalSettings.junctionTargetFieldUniversalIdentifier; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts index 218ce20f11..c2860b5002 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts @@ -1,4 +1,3 @@ -import { msg } from '@lingui/core/macro'; import { RelationType } from 'twenty-shared/types'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; @@ -25,6 +24,15 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({ }): FlatFieldMetadataValidationError[] => { const errors: FlatFieldMetadataValidationError[] = []; + if (!isDefined(universalFlatFieldMetadata.universalSettings?.relationType)) { + errors.push({ + code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + message: `Relation field "${universalFlatFieldMetadata.name}" is missing relationType in universalSettings. Expected MANY_TO_ONE or ONE_TO_MANY.`, + }); + + return errors; + } + switch (universalFlatFieldMetadata.universalSettings.relationType) { case RelationType.MANY_TO_ONE: { if ( @@ -34,7 +42,6 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({ code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, message: 'Many to one field metadata should carry the join column name in its settings', - userFriendlyMessage: msg`A many to one relation field should always declare a join column`, }); return errors; @@ -50,7 +57,6 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({ errors.push({ code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, message: 'Could not find relation field parent flat object', - userFriendlyMessage: msg`Could not find relation field parent flat object`, }); return errors; @@ -74,7 +80,6 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({ code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, message: 'One to many field metadata should not carry the join column name in its settings', - userFriendlyMessage: msg`A one to many relation field should never declare a join column`, }); } break; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts index 11771b5c81..80a8e20b35 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts @@ -14,6 +14,10 @@ export const validateMorphOrRelationFlatFieldOnDelete = ({ }): FlatFieldMetadataValidationError[] => { const errors: FlatFieldMetadataValidationError[] = []; + if (!isDefined(universalFlatFieldMetadata.universalSettings?.relationType)) { + return errors; + } + if ( isDefined(universalFlatFieldMetadata.universalSettings.onDelete) && universalFlatFieldMetadata.universalSettings.relationType !==