diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx
index eee1c8ab75..cdd8f1661e 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx
@@ -10,7 +10,6 @@ import { isActivityTargetField } from '@/object-record/record-field-list/utils/c
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRelationFromManyFieldDisplay';
-import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
@@ -147,7 +146,7 @@ export const RelationFromManyFieldDisplay = () => {
.filter(isDefined);
if (fieldValue.some(isDefined) && targetRecordsWithMetadata.length === 0) {
- return ;
+ return null;
}
return (
diff --git a/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts b/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts
index 2e2d29ba56..af9f8d5de7 100644
--- a/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts
+++ b/packages/twenty-server/src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
import { type FindOptionsRelations, type ObjectLiteral } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -173,7 +174,7 @@ export class ProcessNestedRelationsV2Helper {
targetObjectNameSingular,
);
- const columnsToSelect = buildColumnsToSelect({
+ const columnsToSelect: Record = buildColumnsToSelect({
select: selectedFields,
relations: nestedRelations,
flatObjectMetadata: targetObjectMetadata,
@@ -181,17 +182,22 @@ export class ProcessNestedRelationsV2Helper {
flatFieldMetadataMaps,
});
+ if (relationType === RelationType.MANY_TO_ONE) {
+ columnsToSelect.deletedAt = true;
+ targetObjectQueryBuilder = targetObjectQueryBuilder.withDeleted();
+ }
+
targetObjectQueryBuilder = targetObjectQueryBuilder.setFindOptions({
select: columnsToSelect,
});
+ const joinColumnName =
+ sourceFieldMetadata.settings.joinColumnName ?? `${sourceFieldName}Id`;
+
const relationIds = this.getUniqueIds({
records: parentObjectRecords,
idField:
- relationType === RelationType.ONE_TO_MANY
- ? 'id'
- : (sourceFieldMetadata.settings.joinColumnName ??
- `${sourceFieldName}Id`),
+ relationType === RelationType.ONE_TO_MANY ? 'id' : joinColumnName,
});
const fieldMetadataTargetRelationColumnName =
@@ -227,7 +233,9 @@ export class ProcessNestedRelationsV2Helper {
relationType === RelationType.ONE_TO_MANY
? `${fieldMetadataTargetRelationColumnName}`
: 'id',
+ joinColumnName,
relationType,
+ selectedFields,
});
if (Object.keys(nestedRelations).length > 0) {
@@ -398,7 +406,9 @@ export class ProcessNestedRelationsV2Helper {
relationAggregatedFieldsResult,
sourceFieldName,
joinField,
+ joinColumnName,
relationType,
+ selectedFields,
}: {
parentRecords: ObjectRecord[];
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
@@ -409,7 +419,9 @@ export class ProcessNestedRelationsV2Helper {
relationAggregatedFieldsResult: Record;
sourceFieldName: string;
joinField: string;
+ joinColumnName: string;
relationType: RelationType;
+ selectedFields: Record;
}): void {
parentRecords.forEach((item) => {
if (relationType === RelationType.ONE_TO_MANY) {
@@ -417,10 +429,24 @@ export class ProcessNestedRelationsV2Helper {
(rel) => rel[joinField] === item.id,
);
} else {
- item[sourceFieldName] =
- relationResults.find(
- (rel) => rel.id === item[`${sourceFieldName}Id`],
- ) ?? null;
+ const matchedRelation = relationResults.find(
+ (rel) => rel.id === item[joinColumnName],
+ );
+
+ if (isDefined(matchedRelation?.deletedAt)) {
+ item[sourceFieldName] = null;
+ item[joinColumnName] = null;
+ } else if (isDefined(matchedRelation)) {
+ if (selectedFields?.deletedAt !== true) {
+ const { deletedAt: _, ...rest } = matchedRelation;
+
+ item[sourceFieldName] = rest;
+ } else {
+ item[sourceFieldName] = matchedRelation;
+ }
+ } else {
+ item[sourceFieldName] = null;
+ }
}
});
diff --git a/packages/twenty-server/test/integration/graphql/suites/soft-deleted-relation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/soft-deleted-relation.integration-spec.ts
new file mode 100644
index 0000000000..7e6a426449
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/soft-deleted-relation.integration-spec.ts
@@ -0,0 +1,135 @@
+import { randomUUID } from 'crypto';
+
+import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
+import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
+import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
+import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util';
+import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
+import { restoreOneOperationFactory } from 'test/integration/graphql/utils/restore-one-operation-factory.util';
+
+const PERSON_WITH_COMPANY_GQL_FIELDS = `
+ id
+ companyId
+ company {
+ id
+ name
+ }
+`;
+
+describe('soft-deleted relation', () => {
+ const companyId = randomUUID();
+ const personId = randomUUID();
+
+ beforeAll(async () => {
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: 'id name',
+ data: { id: companyId, name: 'SoftDeleteTestCompany' },
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ createOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS,
+ data: {
+ id: personId,
+ companyId,
+ name: { firstName: 'SoftDeleteTest' },
+ },
+ }),
+ );
+ });
+
+ afterAll(async () => {
+ // Ensure records are not soft-deleted before destroying
+ await makeGraphqlAPIRequest(
+ restoreOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: 'id',
+ recordId: companyId,
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: 'id',
+ recordId: personId,
+ }),
+ );
+
+ await makeGraphqlAPIRequest(
+ destroyOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: 'id',
+ recordId: companyId,
+ }),
+ );
+ });
+
+ it('should return company relation when company is live', async () => {
+ const response = await makeGraphqlAPIRequest(
+ findOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS,
+ filter: { id: { eq: personId } },
+ }),
+ );
+
+ const person = response.body.data.person;
+
+ expect(person.companyId).toBe(companyId);
+ expect(person.company).toBeDefined();
+ expect(person.company.id).toBe(companyId);
+ expect(person.company.name).toBe('SoftDeleteTestCompany');
+ });
+
+ it('should nullify companyId when company is soft-deleted', async () => {
+ await makeGraphqlAPIRequest(
+ deleteOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: 'id deletedAt',
+ recordId: companyId,
+ }),
+ );
+
+ const response = await makeGraphqlAPIRequest(
+ findOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS,
+ filter: { id: { eq: personId } },
+ }),
+ );
+
+ const person = response.body.data.person;
+
+ expect(person.companyId).toBeNull();
+ expect(person.company).toBeNull();
+ });
+
+ it('should restore company relation when company is restored', async () => {
+ await makeGraphqlAPIRequest(
+ restoreOneOperationFactory({
+ objectMetadataSingularName: 'company',
+ gqlFields: 'id deletedAt',
+ recordId: companyId,
+ }),
+ );
+
+ const response = await makeGraphqlAPIRequest(
+ findOneOperationFactory({
+ objectMetadataSingularName: 'person',
+ gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS,
+ filter: { id: { eq: personId } },
+ }),
+ );
+
+ const person = response.body.data.person;
+
+ expect(person.companyId).toBe(companyId);
+ expect(person.company).toBeDefined();
+ expect(person.company.id).toBe(companyId);
+ });
+});