diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordNodeFromRecord.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordNodeFromRecord.test.ts index 0250afad6c..f0b1386db8 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordNodeFromRecord.test.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/getRecordNodeFromRecord.test.ts @@ -3,6 +3,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche import { mockedPersonRecords } from '~/testing/mock-data/generated/data/people/mock-people-data'; import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock'; import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; +import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; const peopleMock = [...mockedPersonRecords]; @@ -96,4 +97,55 @@ describe('getRecordNodeFromRecord', () => { }, }); }); + + it('skips a to-many relation whose value is null instead of crashing', () => { + // Given + const objectMetadataItems: EnrichedObjectMetadataItem[] = + getTestEnrichedObjectMetadataItemsMock(); + const objectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === 'person', + ); + + if (!objectMetadataItem) { + throw new Error('Object metadata item not found'); + } + + const oneToManyRelationField = objectMetadataItem.fields.find( + (field) => + field.type === FieldMetadataType.RELATION && + field.relation?.type === RelationType.ONE_TO_MANY, + ); + + if (!oneToManyRelationField) { + throw new Error('No to-many relation field found on person'); + } + + const record = { + ...peopleMock[0], + [oneToManyRelationField.name]: null, + }; + const recordGqlFields = { + name: true, + [oneToManyRelationField.name]: true, + }; + + // When / Then + expect(() => + getRecordNodeFromRecord({ + objectMetadataItems, + objectMetadataItem, + recordGqlFields, + record, + }), + ).not.toThrow(); + + const result = getRecordNodeFromRecord({ + objectMetadataItems, + objectMetadataItem, + recordGqlFields, + record, + }); + + expect(result).not.toHaveProperty(oneToManyRelationField.name); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts index 5b76e266c8..e67d35d72c 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts @@ -67,6 +67,10 @@ export const getRecordNodeFromRecord = ({ field.type === FieldMetadataType.RELATION && field.relation?.type === RelationType.ONE_TO_MANY ) { + if (!Array.isArray(value)) { + return undefined; + } + const oneToManyObjectMetadataItem = objectMetadataItems.find( (item) => item.namePlural === @@ -99,6 +103,10 @@ export const getRecordNodeFromRecord = ({ field.type === FieldMetadataType.MORPH_RELATION && field.settings?.relationType === RelationType.ONE_TO_MANY ) { + if (!Array.isArray(value)) { + return undefined; + } + if (field.morphRelations?.length === 0) { return undefined; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts index d1bb3676dd..3aa4d66c84 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts @@ -17,6 +17,7 @@ import { import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util'; import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; @@ -221,7 +222,13 @@ export class ObjectRecordsToGraphqlConnectionHelper { objectRecord[fieldMetadataNameWithId]; } - const objectValue = objectRecord[fieldMetadata.name]; + const isToManyRelation = + fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY; + + const objectValue = + !isDefined(objectRecord[fieldMetadata.name]) && isToManyRelation + ? [] + : objectRecord[fieldMetadata.name]; if (!isDefined(objectValue)) { continue; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/find-empty-morph-relation-to-many.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/find-empty-morph-relation-to-many.integration-spec.ts new file mode 100644 index 0000000000..523e02db9b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/morph-relation/find-empty-morph-relation-to-many.integration-spec.ts @@ -0,0 +1,161 @@ +import gql from 'graphql-tag'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; +import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util'; +import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.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 { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { capitalize } from 'twenty-shared/utils'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +describe('empty morph to-many relation read', () => { + let opportunityObjectId = ''; + let personObjectId = ''; + let companyObjectId = ''; + let morphFieldId = ''; + + beforeAll(async () => { + ({ + data: { + createOneObject: { id: opportunityObjectId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'oppForEmptyMorph', + namePlural: 'oppsForEmptyMorph', + labelSingular: 'Opp For Empty Morph', + labelPlural: 'Opps For Empty Morph', + icon: 'IconOpportunity', + }, + })); + + ({ + data: { + createOneObject: { id: personObjectId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'personForEmptyMorph', + namePlural: 'peopleForEmptyMorph', + labelSingular: 'Person For Empty Morph', + labelPlural: 'People For Empty Morph', + icon: 'IconUser', + }, + })); + + ({ + data: { + createOneObject: { id: companyObjectId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'companyForEmptyMorph', + namePlural: 'companiesForEmptyMorph', + labelSingular: 'Company For Empty Morph', + labelPlural: 'Companies For Empty Morph', + icon: 'IconBuildingSkyscraper', + }, + })); + + const createdField = await createMorphRelationBetweenObjects({ + objectMetadataId: opportunityObjectId, + firstTargetObjectMetadataId: personObjectId, + secondTargetObjectMetadataId: companyObjectId, + type: FieldMetadataType.MORPH_RELATION, + relationType: RelationType.ONE_TO_MANY, + name: 'owner', + }); + + morphFieldId = createdField.id; + }); + + afterAll(async () => { + await deleteOneFieldMetadata({ input: { idToDelete: morphFieldId } }).catch( + () => {}, + ); + + for (const idToDelete of [ + opportunityObjectId, + personObjectId, + companyObjectId, + ]) { + await updateOneObjectMetadata({ + expectToFail: false, + input: { idToUpdate: idToDelete, updatePayload: { isActive: false } }, + }); + await deleteOneObjectMetadata({ input: { idToDelete } }); + } + }); + + it('returns an empty connection (not null) for each morph to-many target', async () => { + await makeGraphqlAPIRequestWithApiKey( + createOneOperationFactory({ + objectMetadataSingularName: 'oppForEmptyMorph', + gqlFields: 'id', + data: { name: 'No owners' }, + }), + ); + + const { body: schemaBody } = await makeGraphqlAPIRequestWithApiKey({ + query: gql` + query { + __type(name: "${capitalize('oppForEmptyMorph')}") { + fields { + name + type { + name + ofType { + name + } + } + } + } + } + `, + }); + + const morphConnectionFieldNames: string[] = schemaBody.data.__type.fields + .filter( + (field: { + name: string; + type: { name?: string; ofType?: { name?: string } }; + }) => { + const typeName = field.type?.name ?? field.type?.ofType?.name; + + return ( + field.name.startsWith('owner') && typeName?.endsWith('Connection') + ); + }, + ) + .map((field: { name: string }) => field.name); + + expect(morphConnectionFieldNames.length).toBeGreaterThan(0); + + const gqlFields = [ + 'id', + ...morphConnectionFieldNames.map( + (fieldName) => `${fieldName} { edges { node { id } } }`, + ), + ].join('\n'); + + const { body } = await makeGraphqlAPIRequestWithApiKey( + findManyOperationFactory({ + objectMetadataSingularName: 'oppForEmptyMorph', + objectMetadataPluralName: 'oppsForEmptyMorph', + gqlFields, + }), + ); + + expect(body.errors).toBeUndefined(); + + const node = body.data.oppsForEmptyMorph.edges[0].node; + + for (const fieldName of morphConnectionFieldNames) { + expect(node[fieldName]).toEqual({ edges: [] }); + } + }); +});