Compare commits

...
Author SHA1 Message Date
Charles BochetandCursor b2f0064dd7 fix: nullify foreign keys on soft-delete to prevent misleading "Not shared" display
When a record is soft-deleted, FK references from other records were left
dangling, causing the frontend to show "Not shared" (ForbiddenFieldDisplay)
instead of an empty cell. This mimics PostgreSQL's ON DELETE SET NULL
constraint behavior at the application level for soft-deletes.

Closes #20076

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 23:21:59 +02:00
5 changed files with 545 additions and 2 deletions
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
import { ObjectRecord } from 'twenty-shared/types';
@@ -12,6 +12,7 @@ import {
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { RelationOnDeleteService } from 'src/engine/api/common/common-query-runners/services/relation-on-delete.service';
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
import {
@@ -32,6 +33,9 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer
DeleteManyQueryArgs,
ObjectRecord[]
> {
@Inject()
private readonly relationOnDeleteService: RelationOnDeleteService;
protected readonly operationName = CommonQueryNames.DELETE_MANY;
async run(
@@ -60,7 +64,7 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer
);
const columnsToReturn = buildColumnsToReturn({
select: args.selectedFieldsResult.select,
select: { ...args.selectedFieldsResult.select, id: true },
relations: args.selectedFieldsResult.relations,
flatObjectMetadata,
flatObjectMetadataMaps,
@@ -74,6 +78,20 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer
const deletedRecords = deletedObjectRecords.generatedMaps as ObjectRecord[];
const deletedRecordIds = deletedRecords
.map((record) => record.id)
.filter(isDefined);
if (deletedRecordIds.length > 0) {
await this.relationOnDeleteService.nullifyForeignKeysOnSoftDelete({
deletedRecordIds,
deletedObjectMetadata: flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
workspaceDataSource,
});
}
if (isDefined(args.selectedFieldsResult.relations)) {
await this.processNestedRelationsHelper.processNestedRelations({
flatObjectMetadataMaps,
@@ -11,6 +11,7 @@ import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-qu
import { CommonMergeManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-merge-many-query-runner.service';
import { CommonRestoreManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-restore-many-query-runner.service';
import { CommonRestoreOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-restore-one-query-runner.service';
import { RelationOnDeleteService } from 'src/engine/api/common/common-query-runners/services/relation-on-delete.service';
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
import { CommonUpdateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-one-query-runner.service';
@@ -30,4 +31,5 @@ export const CommonQueryRunners = [
CommonRestoreManyQueryRunnerService,
CommonRestoreOneQueryRunnerService,
CommonMergeManyQueryRunnerService,
RelationOnDeleteService,
];
@@ -0,0 +1,213 @@
import {
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
} from 'twenty-shared/types';
import { RelationOnDeleteService } from 'src/engine/api/common/common-query-runners/services/relation-on-delete.service';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
describe('RelationOnDeleteService', () => {
let service: RelationOnDeleteService;
beforeEach(() => {
service = new RelationOnDeleteService();
});
describe('nullifyForeignKeysOnSoftDelete', () => {
it('should not run any queries when deletedRecordIds is empty', async () => {
const mockWorkspaceDataSource = {
getRepository: jest.fn(),
};
await service.nullifyForeignKeysOnSoftDelete({
deletedRecordIds: [],
deletedObjectMetadata: { id: 'obj-1' } as FlatObjectMetadata,
flatFieldMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
} as FlatEntityMaps<FlatFieldMetadata>,
flatObjectMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
} as FlatEntityMaps<FlatObjectMetadata>,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
workspaceDataSource: mockWorkspaceDataSource as any,
});
expect(mockWorkspaceDataSource.getRepository).not.toHaveBeenCalled();
});
it('should nullify FKs for SET_NULL MANY_TO_ONE relations targeting the deleted object', async () => {
const executeMock = jest.fn().mockResolvedValue({ affected: 1 });
const whereMock = jest.fn().mockReturnValue({ execute: executeMock });
const setMock = jest.fn().mockReturnValue({ where: whereMock });
const updateMock = jest.fn().mockReturnValue({ set: setMock });
const createQueryBuilderMock = jest
.fn()
.mockReturnValue({ update: updateMock });
const mockRepository = {
createQueryBuilder: createQueryBuilderMock,
};
const mockWorkspaceDataSource = {
getRepository: jest.fn().mockReturnValue(mockRepository),
};
const deletedObjectId = 'company-object-id';
const referencingObjectId = 'person-object-id';
const flatFieldMetadataMaps = {
byUniversalIdentifier: {
'person-company-field': {
id: 'field-1',
name: 'company',
type: FieldMetadataType.RELATION,
objectMetadataId: referencingObjectId,
relationTargetObjectMetadataId: deletedObjectId,
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName: 'companyId',
},
} as unknown as FlatFieldMetadata,
},
universalIdentifierById: {
'field-1': 'person-company-field',
},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {
'person-object': {
id: referencingObjectId,
nameSingular: 'person',
} as unknown as FlatObjectMetadata,
},
universalIdentifierById: {
[referencingObjectId]: 'person-object',
},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
await service.nullifyForeignKeysOnSoftDelete({
deletedRecordIds: ['company-record-1', 'company-record-2'],
deletedObjectMetadata: {
id: deletedObjectId,
} as FlatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
workspaceDataSource: mockWorkspaceDataSource as any,
});
expect(mockWorkspaceDataSource.getRepository).toHaveBeenCalledWith(
'person',
);
expect(setMock).toHaveBeenCalledWith({ companyId: null });
expect(whereMock).toHaveBeenCalledWith(
'"companyId" IN (:...ids)',
{ ids: ['company-record-1', 'company-record-2'] },
);
});
it('should NOT nullify FKs for CASCADE relations', async () => {
const mockWorkspaceDataSource = {
getRepository: jest.fn(),
};
const deletedObjectId = 'task-object-id';
const flatFieldMetadataMaps = {
byUniversalIdentifier: {
'tasktarget-task-field': {
id: 'field-2',
name: 'task',
type: FieldMetadataType.RELATION,
objectMetadataId: 'tasktarget-object-id',
relationTargetObjectMetadataId: deletedObjectId,
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.CASCADE,
joinColumnName: 'taskId',
},
} as unknown as FlatFieldMetadata,
},
universalIdentifierById: {
'field-2': 'tasktarget-task-field',
},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
await service.nullifyForeignKeysOnSoftDelete({
deletedRecordIds: ['task-record-1'],
deletedObjectMetadata: {
id: deletedObjectId,
} as FlatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
workspaceDataSource: mockWorkspaceDataSource as any,
});
expect(mockWorkspaceDataSource.getRepository).not.toHaveBeenCalled();
});
it('should NOT nullify FKs for ONE_TO_MANY relations', async () => {
const mockWorkspaceDataSource = {
getRepository: jest.fn(),
};
const deletedObjectId = 'company-object-id';
const flatFieldMetadataMaps = {
byUniversalIdentifier: {
'company-people-field': {
id: 'field-3',
name: 'people',
type: FieldMetadataType.RELATION,
objectMetadataId: deletedObjectId,
relationTargetObjectMetadataId: 'person-object-id',
settings: {
relationType: RelationType.ONE_TO_MANY,
onDelete: RelationOnDeleteAction.SET_NULL,
},
} as unknown as FlatFieldMetadata,
},
universalIdentifierById: {
'field-3': 'company-people-field',
},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatFieldMetadata>;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
await service.nullifyForeignKeysOnSoftDelete({
deletedRecordIds: ['company-record-1'],
deletedObjectMetadata: {
id: deletedObjectId,
} as FlatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
workspaceDataSource: mockWorkspaceDataSource as any,
});
expect(mockWorkspaceDataSource.getRepository).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import {
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource';
type RelationSettings = {
relationType?: string;
onDelete?: string;
joinColumnName?: string | null;
};
@Injectable()
export class RelationOnDeleteService {
async nullifyForeignKeysOnSoftDelete({
deletedRecordIds,
deletedObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
workspaceDataSource,
}: {
deletedRecordIds: string[];
deletedObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
workspaceDataSource: GlobalWorkspaceDataSource;
}): Promise<void> {
if (deletedRecordIds.length === 0) {
return;
}
const incomingSetNullFields = this.findIncomingSetNullRelations(
deletedObjectMetadata,
flatFieldMetadataMaps,
);
const nullifyTasks = incomingSetNullFields.map((field) =>
this.nullifyForeignKeyColumn({
field,
deletedRecordIds,
flatObjectMetadataMaps,
workspaceDataSource,
}),
);
await Promise.all(nullifyTasks);
}
private findIncomingSetNullRelations(
deletedObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): FlatFieldMetadata[] {
return Object.values(flatFieldMetadataMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((field) => {
if (field.type !== FieldMetadataType.RELATION) {
return false;
}
if (field.relationTargetObjectMetadataId !== deletedObjectMetadata.id) {
return false;
}
const settings = field.settings as unknown as
| RelationSettings
| undefined;
return (
settings?.relationType === RelationType.MANY_TO_ONE &&
settings?.onDelete === RelationOnDeleteAction.SET_NULL
);
});
}
private async nullifyForeignKeyColumn({
field,
deletedRecordIds,
flatObjectMetadataMaps,
workspaceDataSource,
}: {
field: FlatFieldMetadata;
deletedRecordIds: string[];
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
workspaceDataSource: GlobalWorkspaceDataSource;
}): Promise<void> {
const referencingObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: field.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!referencingObjectMetadata) {
return;
}
const settings = field.settings as unknown as
| RelationSettings
| undefined;
const joinColumnName = settings?.joinColumnName ?? `${field.name}Id`;
const repository = workspaceDataSource.getRepository(
referencingObjectMetadata.nameSingular,
);
await repository
.createQueryBuilder(referencingObjectMetadata.nameSingular)
.update()
.set({ [joinColumnName]: null } as Record<string, null>)
.where(`"${joinColumnName}" IN (:...ids)`, { ids: deletedRecordIds })
.execute();
}
}
@@ -0,0 +1,189 @@
import { randomUUID } from 'node:crypto';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
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 { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
const PERSON_WITH_COMPANY_GQL_FIELDS = `
id
companyId
name {
firstName
lastName
}
`;
describe('relationOnDeleteNullifyFkOnSoftDelete', () => {
it('should nullify person.companyId when the related company is soft-deleted', async () => {
const companyId = randomUUID();
const personId = randomUUID();
const createCompanyOperation = createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: companyId,
name: 'Test Company For Soft Delete',
},
});
await makeGraphqlAPIRequest(createCompanyOperation);
const createPersonOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS,
data: {
id: personId,
companyId,
name: { firstName: 'Test', lastName: 'Person' },
},
});
const createPersonResponse =
await makeGraphqlAPIRequest(createPersonOperation);
expect(createPersonResponse.body.data.createPerson.companyId).toBe(
companyId,
);
const deleteCompanyOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
recordId: companyId,
});
await makeGraphqlAPIRequest(deleteCompanyOperation);
const findPersonOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_WITH_COMPANY_GQL_FIELDS,
filter: { id: { eq: personId } },
});
const findPersonResponse =
await makeGraphqlAPIRequest(findPersonOperation);
expect(findPersonResponse.body.data.person.companyId).toBeNull();
});
it('should nullify opportunity.companyId when the related company is soft-deleted', async () => {
const companyId = randomUUID();
const opportunityId = randomUUID();
const createCompanyOperation = createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: companyId,
name: 'Test Company For Opportunity',
},
});
await makeGraphqlAPIRequest(createCompanyOperation);
const createOpportunityOperation = createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: `id companyId`,
data: {
id: opportunityId,
companyId,
},
});
const createOpportunityResponse = await makeGraphqlAPIRequest(
createOpportunityOperation,
);
expect(
createOpportunityResponse.body.data.createOpportunity.companyId,
).toBe(companyId);
const deleteCompanyOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
recordId: companyId,
});
await makeGraphqlAPIRequest(deleteCompanyOperation);
const findOpportunityOperation = findOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: `id companyId`,
filter: { id: { eq: opportunityId } },
});
const findOpportunityResponse = await makeGraphqlAPIRequest(
findOpportunityOperation,
);
expect(
findOpportunityResponse.body.data.opportunity.companyId,
).toBeNull();
});
it('should NOT nullify FK when relation has CASCADE onDelete (e.g., taskTarget.taskId)', async () => {
const taskId = randomUUID();
const taskTargetId = randomUUID();
const personId = randomUUID();
const createPersonOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: `id`,
data: { id: personId, name: { firstName: 'FK', lastName: 'Test' } },
});
await makeGraphqlAPIRequest(createPersonOperation);
const createTaskOperation = createOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: `id`,
data: { id: taskId },
});
await makeGraphqlAPIRequest(createTaskOperation);
const createTaskTargetOperation = createOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: `id taskId personId`,
data: {
id: taskTargetId,
taskId,
personId,
},
});
const createTaskTargetResponse = await makeGraphqlAPIRequest(
createTaskTargetOperation,
);
expect(
createTaskTargetResponse.body.data.createTaskTarget.taskId,
).toBe(taskId);
const deleteTaskOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: `id`,
recordId: taskId,
});
await makeGraphqlAPIRequest(deleteTaskOperation);
// taskTarget has CASCADE onDelete for taskId, so the FK should remain
// (CASCADE would hard-delete the child, but soft-delete doesn't cascade)
const findTaskTargetOperation = findOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: `id taskId`,
filter: { id: { eq: taskTargetId } },
});
const findTaskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetOperation,
);
expect(findTaskTargetResponse.body.data.taskTarget.taskId).toBe(
taskId,
);
});
});