[BREAKING_CHANGE/GRAPHQL/OBJECT_METADATA_CREATE_ONE] Remove object/fields/view-fields v1 implementation (#15823)

# Introduction
Remove the v2 feature flag for view-field field-metadata and
object-metadata metadata entities

## Some details
- Disabled nestjs-query for object metadata creation and explicitly
calling it
- removed all v1 integration tests files

## Remarks
Not remove v2 referencing in both filenaming right now will handle that
globally later

## Breaking change
Due to object metadata resolver createOne standardization had to rename
the input from `CreateObjectInput` to `CreateOneObjectInput`
This commit is contained in:
Paul Rastoin
2025-11-17 10:55:19 +01:00
committed by GitHub
parent c1dcda6475
commit a39efeb1ab
75 changed files with 425 additions and 15441 deletions
@@ -1,18 +1,22 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { BeforeCreateOne } from '@ptc-org/nestjs-query-graphql';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import {
type FieldMetadataType,
type FieldMetadataSettings,
type FieldMetadataType,
} from 'twenty-shared/types';
import { Type } from 'class-transformer';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook';
@InputType()
@BeforeCreateOne(BeforeCreateOneObject)
export class CreateObjectInput {
@IsString()
@IsNotEmpty()
@@ -54,9 +58,6 @@ export class CreateObjectInput {
@HideField()
dataSourceId: string;
@HideField()
workspaceId: string;
@HideField()
applicationId?: string;
@@ -81,3 +82,13 @@ export class CreateObjectInput {
@Field({ nullable: true }) // Not nullable to me
isLabelSyncedWithName?: boolean;
}
@InputType()
export class CreateOneObjectInput {
@Type(() => CreateObjectInput)
@ValidateNested()
@Field(() => CreateObjectInput, {
description: 'The object to create',
})
object!: CreateObjectInput;
}
@@ -1,13 +1,11 @@
import { InputType } from '@nestjs/graphql';
import { BeforeDeleteOne, IDField } from '@ptc-org/nestjs-query-graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@InputType()
@BeforeDeleteOne(BeforeDeleteOneObject)
export class DeleteOneObjectInput {
@IDField(() => UUIDScalarType, {
description: 'The id of the record to delete.',
@@ -2,7 +2,6 @@ import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
Authorize,
BeforeDeleteOne,
CursorConnection,
FilterableField,
IDField,
@@ -14,7 +13,6 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@ObjectType('Object')
@Authorize({
@@ -28,7 +26,6 @@ import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metada
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneObject)
@CursorConnection('fields', () => FieldMetadataDTO)
@CursorConnection('indexMetadatas', () => IndexMetadataDTO)
export class ObjectMetadataDTO {
@@ -1,6 +1,5 @@
import { Field, InputType } from '@nestjs/graphql';
import { BeforeUpdateOne } from '@ptc-org/nestjs-query-graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -13,7 +12,6 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
@InputType()
export class UpdateObjectPayload {
@@ -76,7 +74,6 @@ export class UpdateObjectPayload {
}
@InputType()
@BeforeUpdateOne(BeforeUpdateOneObject)
export class UpdateOneObjectInput {
@Type(() => UpdateObjectPayload)
@ValidateNested()
@@ -1,697 +0,0 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
import { type Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
type UpdateObjectPayloadForTest = Omit<
UpdateObjectPayload,
'id' | 'workspaceId'
>;
describe('BeforeUpdateOneObject', () => {
let hook: BeforeUpdateOneObject<UpdateObjectPayload>;
let objectMetadataService: ObjectMetadataService;
let fieldMetadataRepository: Repository<FieldMetadataEntity>;
const mockWorkspaceId = 'workspace-id';
const mockObjectId = 'object-id';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BeforeUpdateOneObject,
{
provide: ObjectMetadataService,
useValue: {
findOneWithinWorkspace: jest.fn(),
},
},
{
provide: getRepositoryToken(FieldMetadataEntity),
useValue: {
findBy: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
getI18nInstance: jest.fn().mockReturnValue({
_: jest.fn().mockReturnValue('mocked-translation'),
}),
},
},
],
}).compile();
hook = module.get<BeforeUpdateOneObject<UpdateObjectPayload>>(
BeforeUpdateOneObject,
);
objectMetadataService = module.get<ObjectMetadataService>(
ObjectMetadataService,
);
fieldMetadataRepository = module.get<Repository<FieldMetadataEntity>>(
getRepositoryToken(FieldMetadataEntity),
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should throw UnauthorizedException if workspaceId is not provided', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: true,
},
};
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: '',
locale: undefined,
}),
).rejects.toThrow(UnauthorizedException);
});
it('should throw BadRequestException if object does not exist', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: true,
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(null as unknown as ObjectMetadataEntity);
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
});
it('should not affect custom objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
nameSingular: 'newName',
isActive: true,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
jest.spyOn(fieldMetadataRepository, 'findBy').mockResolvedValue([]);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
expect(result).toEqual(instance);
});
it('should throw BadRequestException when trying to update non-updatable fields on standard objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
nameSingular: 'newName',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
});
it('should throw BadRequestException when trying to update labels when they are synced with name', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'New Label',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: true,
labelSingular: 'Old Label',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
await expect(
hook.run(instance as UpdateOneInputType<UpdateObjectPayload>, {
workspaceId: mockWorkspaceId,
locale: undefined,
}),
).rejects.toThrow(BadRequestException);
});
it('should handle isActive updates for standard objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: false,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isActive: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
isActive: false,
standardOverrides: {},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle isLabelSyncedWithName updates for standard objects', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isLabelSyncedWithName: true,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
standardOverrides: {
labelSingular: 'Custom Label',
labelPlural: 'Custom Labels',
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
isLabelSyncedWithName: true,
standardOverrides: {
labelSingular: null,
labelPlural: null,
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle labelSingular override when not synced with name', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'New Label',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Default Label',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
labelSingular: 'New Label',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle labelPlural override when not synced with name', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelPlural: 'New Labels',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelPlural: 'Default Labels',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
labelPlural: 'New Labels',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should reset labelSingular override when it matches the original value', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'Default Label',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Default Label',
standardOverrides: {
labelSingular: 'Custom Label',
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
labelSingular: null,
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle description override', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
description: 'New Description',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
description: 'Default Description',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
description: 'New Description',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle icon override', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
icon: 'IconStar',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
icon: 'IconCircle',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
icon: 'IconStar',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle locale-specific translations', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: 'Étiquette',
labelPlural: 'Étiquettes',
description: 'Description en français',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Label',
labelPlural: 'Labels',
description: 'Description',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: 'fr-FR',
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
translations: {
'fr-FR': {
labelSingular: 'Étiquette',
labelPlural: 'Étiquettes',
description: 'Description en français',
},
},
},
},
};
expect(result).toEqual(expectedResult);
});
it('should reset locale-specific translations when they match translated defaults', async () => {
const translatedLabel = 'mocked-translation';
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelSingular: translatedLabel,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isLabelSyncedWithName: false,
labelSingular: 'Label',
standardOverrides: {
translations: {
'fr-FR': {
labelSingular: 'Ancienne Étiquette',
},
},
},
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: 'fr-FR',
},
);
const expectedResult = {
id: mockObjectId,
update: {
standardOverrides: {
translations: {
'fr-FR': {
labelSingular: null,
},
},
},
},
};
expect(result).toEqual(expectedResult);
});
it('should handle multiple updates together', async () => {
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
isActive: false,
isLabelSyncedWithName: false,
labelSingular: 'New Label',
labelPlural: 'New Labels',
icon: 'IconStar',
description: 'New Description',
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: false,
isActive: true,
isLabelSyncedWithName: false,
labelSingular: 'Default Label',
labelPlural: 'Default Labels',
icon: 'IconCircle',
description: 'Default Description',
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
const expectedResult = {
id: mockObjectId,
update: {
isActive: false,
isLabelSyncedWithName: false,
standardOverrides: {
labelSingular: 'New Label',
labelPlural: 'New Labels',
icon: 'IconStar',
description: 'New Description',
},
},
};
expect(result).toEqual(expectedResult);
});
it('should validate label identifier field correctly for custom objects', async () => {
const labelIdentifierFieldId = 'label-field-id';
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
labelIdentifierFieldMetadataId: labelIdentifierFieldId,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
jest
.spyOn(fieldMetadataRepository, 'findBy')
.mockImplementation(async (criteria) => {
expect(criteria).toHaveProperty('workspaceId');
expect(criteria).toHaveProperty('objectMetadataId');
expect(criteria).toHaveProperty('id');
return [{ id: labelIdentifierFieldId } as FieldMetadataEntity];
});
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
expect(result).toEqual(instance);
});
it('should validate image identifier field correctly for custom objects', async () => {
const imageIdentifierFieldId = 'image-field-id';
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
id: mockObjectId,
update: {
imageIdentifierFieldMetadataId: imageIdentifierFieldId,
},
};
const mockObject: Partial<ObjectMetadataEntity> = {
id: mockObjectId,
isCustom: true,
};
jest
.spyOn(objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(mockObject as ObjectMetadataEntity);
jest
.spyOn(fieldMetadataRepository, 'findBy')
.mockImplementation(async (criteria) => {
expect(criteria).toHaveProperty('workspaceId');
expect(criteria).toHaveProperty('objectMetadataId');
expect(criteria).toHaveProperty('id');
return [{ id: imageIdentifierFieldId } as FieldMetadataEntity];
});
const result = await hook.run(
instance as UpdateOneInputType<UpdateObjectPayload>,
{
workspaceId: mockWorkspaceId,
locale: undefined,
},
);
expect(result).toEqual(instance);
});
});
@@ -1,29 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
type BeforeCreateOneHook,
type CreateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
@Injectable()
export class BeforeCreateOneObject<T extends CreateObjectInput>
implements BeforeCreateOneHook<T>
{
async run(
instance: CreateOneInputType<T>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
): Promise<CreateOneInputType<T>> {
const workspaceId = context?.req?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
instance.input.workspaceId = workspaceId;
return instance;
}
}
@@ -1,50 +0,0 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
type BeforeDeleteOneHook,
type DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
@Injectable()
export class BeforeDeleteOneObject implements BeforeDeleteOneHook {
constructor(readonly objectMetadataService: ObjectMetadataService) {}
async run(
instance: DeleteOneInputType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
): Promise<DeleteOneInputType> {
const workspaceId = context?.req?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
if (!objectMetadata.isCustom) {
throw new BadRequestException("Standard Objects can't be deleted");
}
if (objectMetadata.isActive) {
throw new BadRequestException("Active objects can't be deleted");
}
return instance;
}
}
@@ -1,434 +0,0 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
type BeforeUpdateOneHook,
type UpdateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
interface StandardObjectUpdate extends Partial<UpdateObjectPayload> {
standardOverrides?: ObjectStandardOverridesDTO;
}
@Injectable()
export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
implements BeforeUpdateOneHook<T>
{
constructor(
readonly objectMetadataService: ObjectMetadataService,
private readonly i18nService: I18nService,
) {}
// TODO: this logic could be moved to a policy guard
async run(
instance: UpdateOneInputType<T>,
{
workspaceId,
locale,
}: {
workspaceId: string;
locale: keyof typeof APP_LOCALES | undefined;
},
): Promise<UpdateOneInputType<T>> {
if (!workspaceId) {
throw new UnauthorizedException();
}
const objectMetadata = await this.getObjectMetadata(instance, workspaceId);
if (!objectMetadata.isCustom) {
return this.handleStandardObjectUpdate(instance, objectMetadata, locale);
}
return instance;
}
private async getObjectMetadata(
instance: UpdateOneInputType<T>,
workspaceId: string,
) {
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!objectMetadata) {
throw new BadRequestException('Object does not exist');
}
return objectMetadata;
}
private handleStandardObjectUpdate(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
locale: keyof typeof APP_LOCALES | undefined,
): UpdateOneInputType<T> {
const update: StandardObjectUpdate = {};
const updatableFields = ['isActive', 'isLabelSyncedWithName'];
const overridableFields = [
'labelSingular',
'labelPlural',
'icon',
'description',
];
const nonUpdatableFields = Object.keys(instance.update).filter(
(key) =>
!updatableFields.includes(key) && !overridableFields.includes(key),
);
const isUpdatingLabelsWhenSynced =
(instance.update.labelSingular || instance.update.labelPlural) &&
objectMetadata.isLabelSyncedWithName &&
instance.update.isLabelSyncedWithName !== false &&
(instance.update.labelSingular !== objectMetadata.labelSingular ||
instance.update.labelPlural !== objectMetadata.labelPlural);
if (isUpdatingLabelsWhenSynced) {
throw new BadRequestException(
'Cannot update labels when they are synced with name',
);
}
if (nonUpdatableFields.length > 0) {
throw new BadRequestException(
`Only isActive, isLabelSyncedWithName, labelSingular, labelPlural, icon and description fields can be updated for standard objects. Disallowed fields: ${nonUpdatableFields.join(', ')}`,
);
}
// preserve existing overrides
update.standardOverrides = objectMetadata.standardOverrides
? { ...objectMetadata.standardOverrides }
: {};
this.handleActiveField(instance, update);
this.handleLabelSyncedWithNameField(instance, update);
this.handleStandardOverrides(instance, objectMetadata, update, locale);
return {
id: instance.id,
update: update as T,
};
}
private handleActiveField(
instance: UpdateOneInputType<T>,
update: StandardObjectUpdate,
): void {
if (!isDefined(instance.update.isActive)) {
return;
}
update.isActive = instance.update.isActive;
}
private handleLabelSyncedWithNameField(
instance: UpdateOneInputType<T>,
update: StandardObjectUpdate,
): void {
if (!isDefined(instance.update.isLabelSyncedWithName)) {
return;
}
update.isLabelSyncedWithName = instance.update.isLabelSyncedWithName;
if (instance.update.isLabelSyncedWithName === false) {
return;
}
// If setting isLabelSyncedWithName to true, clear label overrides
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.labelSingular = null;
update.standardOverrides.labelPlural = null;
}
private handleStandardOverrides(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale: keyof typeof APP_LOCALES | undefined,
): void {
const hasStandardOverrides =
isDefined(instance.update.description) ||
isDefined(instance.update.icon) ||
isDefined(instance.update.labelSingular) ||
isDefined(instance.update.labelPlural);
if (!hasStandardOverrides) {
return;
}
update.standardOverrides = update.standardOverrides || {};
this.handleIconOverride(instance, objectMetadata, update);
this.handleDescriptionOverride(instance, objectMetadata, update, locale);
this.handleLabelOverrides(instance, objectMetadata, update, locale);
}
private resetOverrideIfMatchesOriginal({
update,
overrideKey,
newValue,
originalValue,
locale,
}: {
update: StandardObjectUpdate;
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon';
newValue: string;
originalValue: string | null;
locale?: keyof typeof APP_LOCALES | undefined;
}): boolean {
if (locale && locale !== SOURCE_LOCALE) {
const wasOverrideReset = this.resetLocalizedOverride(
update,
overrideKey,
newValue,
originalValue,
locale,
);
return wasOverrideReset;
}
const wasOverrideReset = this.resetDefaultOverride(
update,
overrideKey,
newValue,
originalValue,
);
return wasOverrideReset;
}
private resetLocalizedOverride(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
newValue: string,
originalValue: string | null,
locale: keyof typeof APP_LOCALES,
): boolean {
const messageId = generateMessageId(originalValue ?? '');
const i18n = this.i18nService.getI18nInstance(locale);
const translatedMessage = i18n._(messageId);
if (newValue !== translatedMessage) {
return false;
}
// Initialize the translations structure if needed
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.translations =
update.standardOverrides.translations || {};
update.standardOverrides.translations[locale] =
update.standardOverrides.translations[locale] || {};
// Reset the override by setting it to null
const localeTranslations = update.standardOverrides.translations[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localeTranslations as Record<string, any>)[overrideKey] = null;
return true;
}
private resetDefaultOverride(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
newValue: string,
originalValue: string | null,
): boolean {
if (newValue !== originalValue) {
return false;
}
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides[overrideKey] = null;
return true;
}
private setOverrideValue(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
value: string,
locale?: keyof typeof APP_LOCALES,
): void {
update.standardOverrides = update.standardOverrides || {};
const shouldSetLocalizedOverride =
locale && locale !== SOURCE_LOCALE && overrideKey !== 'icon';
if (!shouldSetLocalizedOverride) {
update.standardOverrides[overrideKey] = value;
return;
}
this.setLocalizedOverrideValue(update, overrideKey, value, locale);
}
private setLocalizedOverrideValue(
update: StandardObjectUpdate,
overrideKey: 'labelSingular' | 'labelPlural' | 'description' | 'icon',
value: string,
locale: keyof typeof APP_LOCALES,
): void {
update.standardOverrides = update.standardOverrides || {};
update.standardOverrides.translations =
update.standardOverrides.translations || {};
update.standardOverrides.translations[locale] =
update.standardOverrides.translations[locale] || {};
const localeTranslations = update.standardOverrides.translations[locale];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localeTranslations as Record<string, any>)[overrideKey] = value;
}
private handleDescriptionOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.description)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'description',
newValue: instance.update.description,
originalValue: objectMetadata.description,
locale,
})
) {
return;
}
this.setOverrideValue(
update,
'description',
instance.update.description,
locale,
);
}
private handleIconOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
): void {
if (!isDefined(instance.update.icon)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'icon',
newValue: instance.update.icon,
originalValue: objectMetadata.icon,
locale: undefined,
})
) {
return;
}
this.setOverrideValue(update, 'icon', instance.update.icon);
}
private handleLabelOverrides(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
// Skip label updates if labels are synced with name or will be synced
if (
objectMetadata.isLabelSyncedWithName ||
update.isLabelSyncedWithName === true
) {
return;
}
this.handleLabelSingularOverride(instance, objectMetadata, update, locale);
this.handleLabelPluralOverride(instance, objectMetadata, update, locale);
}
private handleLabelSingularOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.labelSingular)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'labelSingular',
newValue: instance.update.labelSingular,
originalValue: objectMetadata.labelSingular,
locale,
})
) {
return;
}
this.setOverrideValue(
update,
'labelSingular',
instance.update.labelSingular,
locale,
);
}
private handleLabelPluralOverride(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
update: StandardObjectUpdate,
locale?: keyof typeof APP_LOCALES,
): void {
if (!isDefined(instance.update.labelPlural)) {
return;
}
if (
this.resetOverrideIfMatchesOriginal({
update,
overrideKey: 'labelPlural',
newValue: instance.update.labelPlural,
originalValue: objectMetadata.labelPlural,
locale,
})
) {
return;
}
this.setOverrideValue(
update,
'labelPlural',
instance.update.labelPlural,
locale,
);
}
}
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils';
import { FindManyOptions, FindOneOptions, Repository } from 'typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util';
@@ -10,15 +13,14 @@ import { FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metada
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util';
import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util';
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
@@ -33,21 +35,25 @@ import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspa
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@Injectable()
export class ObjectMetadataServiceV2 {
export class ObjectMetadataServiceV2 extends TypeOrmQueryService<ObjectMetadataEntity> {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
) {
super(objectMetadataRepository);
}
async updateOne({
async updateOneObject({
updateObjectInput,
workspaceId,
}: {
workspaceId: string;
updateObjectInput: UpdateOneObjectInput;
}): Promise<ObjectMetadataDTO> {
}): Promise<FlatObjectMetadata> {
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatIndexMaps: existingFlatIndexMaps,
@@ -154,10 +160,10 @@ export class ObjectMetadataServiceV2 {
);
}
return fromFlatObjectMetadataToObjectMetadataDto(updatedFlatObjectMetadata);
return updatedFlatObjectMetadata;
}
async deleteOne({
async deleteOneObject({
deleteObjectInput,
workspaceId,
isSystemBuild = false,
@@ -165,7 +171,7 @@ export class ObjectMetadataServiceV2 {
deleteObjectInput: DeleteOneObjectInput;
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<ObjectMetadataDTO> {
}): Promise<FlatObjectMetadata> {
const deletedObjectMetadataDtos = await this.deleteManyObjectMetadatas({
deleteObjectInputs: [deleteObjectInput],
workspaceId,
@@ -192,7 +198,7 @@ export class ObjectMetadataServiceV2 {
deleteObjectInputs: DeleteOneObjectInput[];
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<ObjectMetadataDTO[]> {
}): Promise<FlatObjectMetadata[]> {
if (deleteObjectInputs.length === 0) {
return [];
}
@@ -311,16 +317,14 @@ export class ObjectMetadataServiceV2 {
);
}
return flatObjectMetadatasToDelete.map(
fromFlatObjectMetadataToObjectMetadataDto,
);
return flatObjectMetadatasToDelete;
}
async createOne({
async createOneObject({
createObjectInput,
workspaceId,
}: {
createObjectInput: Omit<CreateObjectInput, 'workspaceId'>;
createObjectInput: CreateObjectInput;
workspaceId: string;
}): Promise<FlatObjectMetadata> {
const {
@@ -549,4 +553,43 @@ export class ObjectMetadataServiceV2 {
isSystemBuild: true,
});
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
): Promise<ObjectMetadataEntity | null> {
return this.objectMetadataRepository.findOne({
relations: [
'fields',
'indexMetadatas',
'indexMetadatas.indexFieldMetadatas',
],
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async findManyWithinWorkspace(
workspaceId: string,
options?: FindManyOptions<ObjectMetadataEntity>,
) {
return this.objectMetadataRepository.find({
relations: [
'fields.object',
'fields',
'fields.relationTargetObjectMetadata',
],
...options,
where: {
...options?.where,
workspaceId,
},
order: {
...options?.order,
},
});
}
}
@@ -11,7 +11,6 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -21,20 +20,13 @@ import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataFieldRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-field-relation.service';
import { ObjectMetadataMigrationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service';
import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-related-records.service';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { RemoteTableRelationsModule } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.module';
import { SearchVectorModule } from 'src/engine/metadata-modules/search-vector/search-vector.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
@@ -63,7 +55,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
WorkspaceMigrationRunnerModule,
WorkspaceMetadataVersionModule,
RemoteTableRelationsModule,
SearchVectorModule,
IndexMetadataModule,
PermissionsModule,
WorkspacePermissionsCacheModule,
@@ -77,13 +68,9 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
services: [
ObjectMetadataService,
ObjectMetadataServiceV2,
FlatFieldMetadataValidatorService,
FlatFieldMetadataTypeValidatorService,
ObjectMetadataMigrationService,
ObjectMetadataFieldRelationService,
ObjectMetadataRelatedRecordsService,
],
resolvers: [
{
@@ -91,14 +78,13 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
DTOClass: ObjectMetadataDTO,
CreateDTOClass: CreateObjectInput,
UpdateDTOClass: UpdateObjectPayload,
ServiceClass: ObjectMetadataService,
ServiceClass: ObjectMetadataServiceV2,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
},
create: {
many: { disabled: true },
guards: [SettingsPermissionGuard(PermissionFlagType.DATA_MODEL)],
disabled: true,
},
update: { disabled: true },
delete: { disabled: true },
@@ -109,12 +95,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
],
}),
],
providers: [
ObjectMetadataService,
ObjectMetadataServiceV2,
ObjectMetadataResolver,
BeforeUpdateOneObject,
],
exports: [ObjectMetadataService, ObjectMetadataServiceV2],
providers: [ObjectMetadataServiceV2, ObjectMetadataResolver],
exports: [ObjectMetadataServiceV2],
})
export class ObjectMetadataModule {}
@@ -8,8 +8,6 @@ import {
Resolver,
} from '@nestjs/graphql';
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 { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -20,16 +18,13 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { CreateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import {
UpdateOneObjectInput,
type UpdateObjectPayload,
} from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@@ -44,9 +39,6 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
)
export class ObjectMetadataResolver {
constructor(
private readonly objectMetadataService: ObjectMetadataService,
private readonly beforeUpdateOneObject: BeforeUpdateOneObject<UpdateObjectPayload>,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly i18nService: I18nService,
) {}
@@ -114,16 +106,42 @@ export class ObjectMetadataResolver {
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => ObjectMetadataDTO)
async deleteOneObject(
@Args('input') input: DeleteOneObjectInput,
async createOneObject(
@Args('input') input: CreateOneObjectInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
return await this.objectMetadataService.deleteOneObject(
input,
workspaceId,
const flatobjectMetadata =
await this.objectMetadataServiceV2.createOneObject({
createObjectInput: input.object,
workspaceId,
});
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
}
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => ObjectMetadataDTO)
async deleteOneObject(
@Args('input') deleteObjectInput: DeleteOneObjectInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
try {
const flatobjectMetadata =
await this.objectMetadataServiceV2.deleteOneObject({
deleteObjectInput,
workspaceId,
});
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
@@ -139,39 +157,14 @@ export class ObjectMetadataResolver {
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Context() context: I18nContext,
) {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
try {
return await this.objectMetadataServiceV2.updateOne({
try {
const flatobjectMetadata =
await this.objectMetadataServiceV2.updateOneObject({
updateObjectInput,
workspaceId,
});
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
this.i18nService.getI18nInstance(context.req.locale),
);
}
}
try {
const updatedInput = (await this.beforeUpdateOneObject.run(
updateObjectInput,
{
workspaceId,
locale: context.req.locale,
},
)) as UpdateOneObjectInput;
return await this.objectMetadataService.updateOneObject(
updatedInput,
workspaceId,
);
return fromFlatObjectMetadataToObjectMetadataDto(flatobjectMetadata);
} catch (error) {
objectMetadataGraphqlApiExceptionHandler(
error,
@@ -1,792 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { type Query, type QueryOptions } from '@ptc-org/nestjs-query-core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
DataSource,
In,
Repository,
type FindManyOptions,
type FindOneOptions,
type QueryRunner,
} from 'typeorm';
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 { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { type DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import {
type UpdateObjectPayload,
type UpdateOneObjectInput,
} from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { ObjectMetadataFieldRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-field-relation.service';
import { ObjectMetadataMigrationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service';
import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-related-records.service';
import { buildDefaultFieldsForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util';
import {
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow,
validateObjectMetadataInputLabelsOrThrow,
validateObjectMetadataInputNamesOrThrow,
} from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
import { SearchVectorService } from 'src/engine/metadata-modules/search-vector/search-vector.service';
import { type 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 { validateMetadataIdentifierFieldMetadataIds } from 'src/engine/metadata-modules/utils/validate-metadata-identifier-field-metadata-id.utils';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { validatesNoOtherObjectWithSameNameExistsOrThrows } from 'src/engine/metadata-modules/utils/validate-no-other-object-with-same-name-exists-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 { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
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 { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { isSearchableFieldType } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
import { ObjectMetadataEntity } from './object-metadata.entity';
import { type CreateObjectInput } from './dtos/create-object.input';
@Injectable()
export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEntity> {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly dataSourceService: DataSourceService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly searchVectorService: SearchVectorService,
private readonly objectMetadataFieldRelationService: ObjectMetadataFieldRelationService,
private readonly objectMetadataMigrationService: ObjectMetadataMigrationService,
private readonly objectMetadataRelatedRecordsService: ObjectMetadataRelatedRecordsService,
private readonly indexMetadataService: IndexMetadataService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
) {
super(objectMetadataRepository);
}
override async query(
query: Query<ObjectMetadataEntity>,
opts?: QueryOptions<ObjectMetadataEntity> | undefined,
): Promise<ObjectMetadataEntity[]> {
const start = performance.now();
const result = super.query(query, opts);
const end = performance.now();
// eslint-disable-next-line no-console
console.log(`metadata query time: ${end - start} ms`);
return result;
}
override async createOne(
createObjectInput: CreateObjectInput,
): Promise<ObjectMetadataEntity> {
const { workspaceId } = createObjectInput;
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
const flatObjectMetadata = await this.objectMetadataServiceV2.createOne({
createObjectInput,
workspaceId,
});
const createdObjectMetadata = await this.objectMetadataRepository.findOne(
{
where: {
id: flatObjectMetadata.id,
workspaceId,
},
},
);
if (!isDefined(createdObjectMetadata)) {
throw new ObjectMetadataException(
'Created object metadata not found',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
return createdObjectMetadata;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{
workspaceId,
},
);
const lastDataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
createObjectInput.labelSingular = capitalize(
createObjectInput.labelSingular,
);
createObjectInput.labelPlural = capitalize(createObjectInput.labelPlural);
validateObjectMetadataInputNamesOrThrow(createObjectInput);
validateObjectMetadataInputLabelsOrThrow(createObjectInput);
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [createObjectInput.nameSingular, createObjectInput.namePlural],
message:
'The singular and plural names cannot be the same for an object',
});
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [
createObjectInput.labelPlural,
createObjectInput.labelSingular,
],
message:
'The singular and plural labels cannot be the same for an object',
});
if (createObjectInput.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow({
label: createObjectInput.labelSingular,
name: createObjectInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: createObjectInput.labelPlural,
name: createObjectInput.namePlural,
});
}
validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNamePlural: createObjectInput.namePlural,
objectMetadataNameSingular: createObjectInput.nameSingular,
objectMetadataMaps,
});
const baseCustomFields = buildDefaultFieldsForCustomObject(workspaceId);
const labelIdentifierFieldMetadataId = baseCustomFields.find(
(field) => field.standardId === CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
)?.id;
if (!isDefined(labelIdentifierFieldMetadataId)) {
throw new ObjectMetadataException(
'Label identifier field metadata not created properly',
ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD,
);
}
const createdObjectMetadata = await objectMetadataRepository.save({
...createObjectInput,
dataSourceId: lastDataSourceMetadata.id,
targetTableName: 'DEPRECATED',
isActive: true,
isCustom: !createObjectInput.isRemote,
isSystem: false,
isRemote: createObjectInput.isRemote,
isSearchable: !createObjectInput.isRemote,
fields: createObjectInput.isRemote ? [] : baseCustomFields,
labelIdentifierFieldMetadataId,
});
if (createObjectInput.isRemote) {
throw new Error('Remote objects are not supported yet');
} else {
const fieldsById = createdObjectMetadata.fields.reduce(
(acc, field) => ({
...acc,
[field.id]: field,
}),
{},
);
const createdRelatedObjectMetadataCollection =
await this.objectMetadataFieldRelationService.createRelationsAndForeignKeysMetadata(
workspaceId,
{ ...createdObjectMetadata, fieldsById },
objectMetadataMaps,
queryRunner,
);
await this.objectMetadataMigrationService.createTableMigration(
createdObjectMetadata,
queryRunner,
);
await this.objectMetadataMigrationService.createColumnsMigrations(
createdObjectMetadata,
createdObjectMetadata.fields,
queryRunner,
);
await this.objectMetadataMigrationService.createRelationMigrations(
createdObjectMetadata,
createdRelatedObjectMetadataCollection,
queryRunner,
);
await this.searchVectorService.createSearchVectorFieldForObject(
createObjectInput,
createdObjectMetadata,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
createdObjectMetadata.workspaceId,
queryRunner,
);
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
await this.objectMetadataRelatedRecordsService.createObjectRelatedRecords(
createdObjectMetadata,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
});
return createdObjectMetadata;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async updateOneObject(
updateObjectInput: UpdateOneObjectInput,
workspaceId: string,
): Promise<ObjectMetadataEntity> {
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{ workspaceId },
);
const inputId = updateObjectInput.id;
const inputPayload = {
...updateObjectInput.update,
...(isDefined(updateObjectInput.update.labelSingular)
? {
labelSingular: capitalize(updateObjectInput.update.labelSingular),
}
: {}),
...(isDefined(updateObjectInput.update.labelPlural)
? { labelPlural: capitalize(updateObjectInput.update.labelPlural) }
: {}),
};
validateObjectMetadataInputNamesOrThrow(inputPayload);
const existingObjectMetadata = objectMetadataMaps.byId[inputId];
if (!existingObjectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const existingObjectMetadataCombinedWithUpdateInput = {
...existingObjectMetadata,
...inputPayload,
};
validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNameSingular:
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
objectMetadataNamePlural:
existingObjectMetadataCombinedWithUpdateInput.namePlural,
existingObjectMetadataId:
existingObjectMetadataCombinedWithUpdateInput.id,
objectMetadataMaps,
});
if (existingObjectMetadataCombinedWithUpdateInput.isLabelSyncedWithName) {
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelSingular,
name: existingObjectMetadataCombinedWithUpdateInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelPlural,
name: existingObjectMetadataCombinedWithUpdateInput.namePlural,
});
}
if (
isDefined(inputPayload.nameSingular) ||
isDefined(inputPayload.namePlural)
) {
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
inputs: [
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
existingObjectMetadataCombinedWithUpdateInput.namePlural,
],
message:
'The singular and plural names cannot be the same for an object',
});
}
validateMetadataIdentifierFieldMetadataIds({
fieldMetadataItems: Object.values(existingObjectMetadata.fieldsById),
labelIdentifierFieldMetadataId:
inputPayload.labelIdentifierFieldMetadataId,
imageIdentifierFieldMetadataId:
inputPayload.imageIdentifierFieldMetadataId,
});
const updatedObject = await objectMetadataRepository.save({
...existingObjectMetadata,
...inputPayload,
});
const { didUpdateLabelOrIcon } =
await this.handleObjectNameAndLabelUpdates({
existingObjectMetadata,
objectMetadataForUpdate:
existingObjectMetadataCombinedWithUpdateInput,
inputPayload,
queryRunner,
objectMetadataMaps,
});
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
if (inputPayload.labelIdentifierFieldMetadataId) {
const labelIdentifierFieldMetadata =
existingObjectMetadata.fieldsById[
inputPayload.labelIdentifierFieldMetadataId
];
if (isSearchableFieldType(labelIdentifierFieldMetadata.type)) {
await this.searchVectorService.updateSearchVector(
inputId,
[
{
name: labelIdentifierFieldMetadata.name,
type: labelIdentifierFieldMetadata.type,
},
],
workspaceId,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
}
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
if (didUpdateLabelOrIcon) {
await this.objectMetadataRelatedRecordsService.updateObjectViews(
updatedObject,
workspaceId,
);
}
if (
isDefined(inputPayload.labelIdentifierFieldMetadataId) &&
inputPayload.labelIdentifierFieldMetadataId !==
existingObjectMetadata.labelIdentifierFieldMetadataId
) {
const labelIdentifierFieldMetadata =
existingObjectMetadata.fieldsById[
inputPayload.labelIdentifierFieldMetadataId
];
await this.objectMetadataRelatedRecordsService.updateLabelMetadataIdentifierInObjectViews(
{
newLabelMetadataIdentifierFieldMetadata:
labelIdentifierFieldMetadata,
},
);
}
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
});
const formattedUpdatedObject = {
...updatedObject,
createdAt: new Date(updatedObject.createdAt),
updatedAt: new Date(updatedObject.updatedAt),
};
return formattedUpdatedObject;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async deleteOneObject(
deleteObjectInput: DeleteOneObjectInput,
workspaceId: string,
): Promise<Partial<ObjectMetadataEntity>> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.objectMetadataServiceV2.deleteOne({
deleteObjectInput,
workspaceId,
});
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const objectMetadataRepository =
queryRunner.manager.getRepository(ObjectMetadataEntity);
const fieldMetadataRepository =
queryRunner.manager.getRepository(FieldMetadataEntity);
const objectMetadata = await objectMetadataRepository.findOne({
relations: [
'fields',
'fields.object',
'fields.relationTargetFieldMetadata',
'fields.relationTargetFieldMetadata.object',
],
where: {
id: deleteObjectInput.id,
workspaceId,
},
});
if (!objectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
if (objectMetadata.isRemote) {
throw new ObjectMetadataException(
'Remote objects are not supported yet',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
} else {
await this.objectMetadataMigrationService.deleteAllRelationsAndDropTable(
objectMetadata,
workspaceId,
queryRunner,
);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrationsWithinTransaction(
workspaceId,
queryRunner,
);
const fieldMetadataIds = objectMetadata.fields.map((field) => field.id);
const relationMetadataIds = objectMetadata.fields.flatMap((field) => {
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)
) {
return field.relationTargetFieldMetadata.id;
}
return [];
});
await fieldMetadataRepository.delete({
id: In(fieldMetadataIds.concat(relationMetadataIds)),
});
await objectMetadataRepository.delete(objectMetadata.id);
await queryRunner.commitTransaction();
// After commit, do non-transactional work
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
});
await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache(
{
workspaceId,
},
);
await this.objectMetadataRelatedRecordsService.deleteObjectViews(
objectMetadata,
workspaceId,
);
return objectMetadata;
} catch (error) {
if (queryRunner.isTransactionActive) {
try {
await queryRunner.rollbackTransaction();
} catch (error) {
// eslint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`);
}
}
throw error;
} finally {
await queryRunner.release();
}
}
public async findOneWithinWorkspace(
workspaceId: string,
options: FindOneOptions<ObjectMetadataEntity>,
): Promise<ObjectMetadataEntity | null> {
return this.objectMetadataRepository.findOne({
relations: [
'fields',
'indexMetadatas',
'indexMetadatas.indexFieldMetadatas',
],
...options,
where: {
...options.where,
workspaceId,
},
});
}
public async findManyWithinWorkspace(
workspaceId: string,
options?: FindManyOptions<ObjectMetadataEntity>,
) {
return this.objectMetadataRepository.find({
relations: [
'fields.object',
'fields',
'fields.relationTargetObjectMetadata',
],
...options,
where: {
...options?.where,
workspaceId,
},
order: {
...options?.order,
},
});
}
public async deleteObjectsMetadata(workspaceId: string) {
const objectsMetadata = await this.objectMetadataRepository.find({
where: {
workspaceId,
},
});
await this.fieldMetadataRepository.delete({
workspaceId,
type: In([FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION]),
});
for (const objectMetadata of objectsMetadata) {
await this.objectMetadataRepository.delete({
id: objectMetadata.id,
});
}
}
private async handleObjectNameAndLabelUpdates({
existingObjectMetadata,
objectMetadataForUpdate,
inputPayload,
queryRunner,
objectMetadataMaps,
}: {
existingObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>;
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
| 'isCustom'
| 'workspaceId'
| 'id'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'fieldsById'
>;
inputPayload: UpdateObjectPayload;
queryRunner: QueryRunner;
objectMetadataMaps: ObjectMetadataMaps;
}): Promise<{ didUpdateLabelOrIcon: boolean }> {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
if (newTargetTableName !== existingTargetTableName) {
await this.objectMetadataMigrationService.createRenameTableMigration(
existingObjectMetadata,
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const relationMetadataCollection =
await this.objectMetadataFieldRelationService.updateRelationsAndForeignKeysMetadata(
{
workspaceId: objectMetadataForUpdate.workspaceId,
updatedObjectMetadata: objectMetadataForUpdate,
queryRunner,
objectMetadataMaps,
},
);
await this.objectMetadataMigrationService.updateRelationMigrations(
existingObjectMetadata,
objectMetadataForUpdate,
relationMetadataCollection,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const morphRelationFieldMetadataToUpdate =
await this.objectMetadataFieldRelationService.updateMorphRelationsJoinColumnName(
{
existingObjectMetadata,
objectMetadataForUpdate,
queryRunner,
},
);
await this.objectMetadataMigrationService.updateMorphRelationMigrations({
workspaceId: objectMetadataForUpdate.workspaceId,
morphRelationFieldMetadataToUpdate: morphRelationFieldMetadataToUpdate,
queryRunner,
});
await this.objectMetadataMigrationService.recomputeEnumNames(
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
queryRunner,
);
const recomputedIndexes =
await this.indexMetadataService.recomputeIndexMetadataForObject(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
queryRunner,
);
await this.indexMetadataService.createIndexRecomputeMigrations(
objectMetadataForUpdate.workspaceId,
objectMetadataForUpdate,
recomputedIndexes,
queryRunner,
);
if (
(inputPayload.labelPlural || inputPayload.icon) &&
(inputPayload.labelPlural !== existingObjectMetadata.labelPlural ||
inputPayload.icon !== existingObjectMetadata.icon)
) {
return {
didUpdateLabelOrIcon: true,
};
}
}
return {
didUpdateLabelOrIcon: false,
};
}
}
@@ -1,617 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { FieldMetadataType, RelationOnDeleteAction } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { type QueryRunner, Repository } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildDescriptionForRelationFieldMetadataOnFromField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-from-field.util';
import { buildDescriptionForRelationFieldMetadataOnToField } from 'src/engine/metadata-modules/object-metadata/utils/build-description-for-relation-field-on-to-field.util';
import { type FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type 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 {
CUSTOM_OBJECT_STANDARD_FIELD_IDS,
STANDARD_OBJECT_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { createRelationDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
export const DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS = [
STANDARD_OBJECT_IDS.timelineActivity,
STANDARD_OBJECT_IDS.favorite,
STANDARD_OBJECT_IDS.attachment,
STANDARD_OBJECT_IDS.noteTarget,
STANDARD_OBJECT_IDS.taskTarget,
];
type PartialRelationFieldMetadata = Partial<
FieldMetadataEntity<FieldMetadataType.RELATION>
> &
Required<Pick<FieldMetadataEntity<FieldMetadataType.RELATION>, 'name'>>;
@Injectable()
export class ObjectMetadataFieldRelationService {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {}
public async createRelationsAndForeignKeysMetadata(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>,
objectMetadataMaps: ObjectMetadataMaps,
queryRunner?: QueryRunner,
) {
const relatedObjectMetadataCollection = await Promise.all(
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
async (relationObjectMetadataStandardId) =>
this.createRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata,
relationObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}),
),
);
return relatedObjectMetadataCollection;
}
private async createRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata,
relationObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>;
objectMetadataMaps: ObjectMetadataMaps;
relationObjectMetadataStandardId: string;
queryRunner?: QueryRunner;
}) {
const targetObjectMetadata = Object.values(objectMetadataMaps.byId)
.filter(isDefined)
.find(
(objectMetadata) =>
objectMetadata.standardId === relationObjectMetadataStandardId,
);
if (!targetObjectMetadata) {
throw new Error(
`Target object metadata not found for standard ID: ${relationObjectMetadataStandardId}`,
);
}
await this.createFieldMetadataRelation(
workspaceId,
sourceObjectMetadata,
targetObjectMetadata,
queryRunner,
);
return targetObjectMetadata;
}
private async createFieldMetadataRelation(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'id' | 'nameSingular' | 'labelSingular' | 'fieldsById'
>,
targetObjectMetadata: ObjectMetadataItemWithFieldMaps,
queryRunner?: QueryRunner,
): Promise<FieldMetadataEntity<FieldMetadataType.RELATION>[]> {
const sourceFieldMetadata = this.createSourceFieldMetadata(
workspaceId,
sourceObjectMetadata,
targetObjectMetadata,
);
this.validateFieldNameAvailabilityOrThrow({
name: sourceFieldMetadata.name,
fieldMetadataMapById: sourceObjectMetadata.fieldsById,
});
const targetFieldMetadata = this.createTargetFieldMetadata(
workspaceId,
sourceObjectMetadata,
targetObjectMetadata,
);
this.validateFieldNameAvailabilityOrThrow({
name: targetFieldMetadata.name,
fieldMetadataMapById: sourceObjectMetadata.fieldsById,
});
const fieldMetadataRepository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
return fieldMetadataRepository.save([
{
...sourceFieldMetadata,
settings: {
relationType: RelationType.ONE_TO_MANY,
},
relationTargetObjectMetadataId: targetObjectMetadata.id,
relationTargetFieldMetadataId: targetFieldMetadata.id,
} as Partial<FieldMetadataEntity<FieldMetadataType.RELATION>>,
{
...targetFieldMetadata,
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.CASCADE,
joinColumnName: `${sourceObjectMetadata.nameSingular}Id`,
},
relationTargetObjectMetadataId: sourceObjectMetadata.id,
relationTargetFieldMetadataId: sourceFieldMetadata.id,
} as Partial<FieldMetadataEntity<FieldMetadataType.RELATION>>,
]);
}
public async updateRelationsAndForeignKeysMetadata({
workspaceId,
updatedObjectMetadata,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
updatedObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom' | 'id' | 'labelSingular'
>;
objectMetadataMaps: ObjectMetadataMaps;
queryRunner?: QueryRunner;
}): Promise<
{
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}[]
> {
return await Promise.all(
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
async (relationObjectMetadataStandardId) =>
this.updateRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata: updatedObjectMetadata,
targetObjectMetadataStandardId: relationObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}),
),
);
}
private async updateRelationAndForeignKeyMetadata({
workspaceId,
sourceObjectMetadata,
targetObjectMetadataStandardId,
objectMetadataMaps,
queryRunner,
}: {
workspaceId: string;
sourceObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'id' | 'isCustom' | 'labelSingular'
>;
targetObjectMetadataStandardId: string;
objectMetadataMaps: ObjectMetadataMaps;
queryRunner?: QueryRunner;
}): Promise<{
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}> {
const fieldMetadataRepository = queryRunner
? queryRunner.manager.getRepository(FieldMetadataEntity)
: this.fieldMetadataRepository;
const targetObjectMetadataId = Object.values(objectMetadataMaps.byId).find(
(objectMetadata) =>
objectMetadata?.standardId === targetObjectMetadataStandardId &&
objectMetadata.isCustom === false,
)?.id;
if (!targetObjectMetadataId) {
throw new Error(
`Target object metadata id not found for standard ID: ${targetObjectMetadataStandardId}`,
);
}
const targetObjectMetadata =
objectMetadataMaps.byId[targetObjectMetadataId];
if (!targetObjectMetadata) {
throw new Error(
`Target object metadata not found for id: ${targetObjectMetadataId}`,
);
}
const targetFieldMetadataUpdateData = this.updateTargetFieldMetadata(
sourceObjectMetadata,
targetObjectMetadata,
);
const targetFieldMetadataToUpdate =
await fieldMetadataRepository.findOneByOrFail({
standardId: createRelationDeterministicUuid({
objectId: sourceObjectMetadata.id,
standardId:
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom,
}),
objectMetadataId: targetObjectMetadata.id,
workspaceId: workspaceId,
});
const nameIsUpdated =
targetFieldMetadataUpdateData.name !== targetFieldMetadataToUpdate.name;
if (nameIsUpdated) {
const targetObjectMetadataFieldsById =
objectMetadataMaps.byId[targetObjectMetadata.id]?.fieldsById;
if (!targetObjectMetadataFieldsById) {
throw new Error(
`Target object metadata fields not found for ${targetObjectMetadata.id}`,
);
}
this.validateFieldNameAvailabilityOrThrow({
name: targetFieldMetadataUpdateData.name,
fieldMetadataMapById: targetObjectMetadataFieldsById,
});
}
const isTargetFieldMetadataManyToOneRelation =
(
targetFieldMetadataToUpdate as FieldMetadataEntity<FieldMetadataType.RELATION>
).settings?.relationType === RelationType.MANY_TO_ONE;
const targetFieldMetadata = await fieldMetadataRepository.save({
id: targetFieldMetadataToUpdate.id,
...targetFieldMetadataUpdateData,
settings: {
...targetFieldMetadataToUpdate.settings,
...(isTargetFieldMetadataManyToOneRelation
? {
joinColumnName: `${sourceObjectMetadata.nameSingular}Id`,
}
: {}),
},
});
const sourceFieldMetadataUpdateData = this.updateSourceFieldMetadata(
sourceObjectMetadata,
targetObjectMetadata,
);
const sourceFieldMetadataToUpdate =
await fieldMetadataRepository.findOneByOrFail({
standardId:
// @ts-expect-error legacy noImplicitAny
CUSTOM_OBJECT_STANDARD_FIELD_IDS[targetObjectMetadata.namePlural],
objectMetadataId: sourceObjectMetadata.id,
workspaceId: workspaceId,
});
const isSourceFieldMetadataManyToOneRelation =
(
sourceFieldMetadataToUpdate as FieldMetadataEntity<FieldMetadataType.RELATION>
).settings?.relationType === RelationType.MANY_TO_ONE;
const sourceFieldMetadata = await fieldMetadataRepository.save({
id: sourceFieldMetadataToUpdate.id,
...sourceFieldMetadataUpdateData,
settings: {
...sourceFieldMetadataToUpdate.settings,
...(isSourceFieldMetadataManyToOneRelation
? {
joinColumnName: `${targetObjectMetadata.nameSingular}Id`,
}
: {}),
},
});
return {
targetObjectMetadata,
targetFieldMetadata,
sourceFieldMetadata,
};
}
private createSourceFieldMetadata(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'labelSingular' | 'id'
>,
targetObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'namePlural' | 'labelSingular'
>,
): PartialRelationFieldMetadata {
const relationObjectMetadataNamePlural = targetObjectMetadata.namePlural;
const { description } = buildDescriptionForRelationFieldMetadataOnFromField(
{
relationObjectMetadataNamePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
},
);
return {
id: uuidV4(),
standardId:
// @ts-expect-error legacy noImplicitAny
CUSTOM_OBJECT_STANDARD_FIELD_IDS[relationObjectMetadataNamePlural],
objectMetadataId: sourceObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
isSystem: true,
type: FieldMetadataType.RELATION,
name: targetObjectMetadata.namePlural,
label: capitalize(relationObjectMetadataNamePlural),
description,
icon:
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_ICONS[targetObjectMetadata.nameSingular] ||
'IconBuildingSkyscraper',
isNullable: true,
};
}
private updateSourceFieldMetadata(
sourceObjectMetadata: Pick<ObjectMetadataEntity, 'labelSingular'>,
targetObjectMetadata: Pick<ObjectMetadataEntity, 'namePlural'>,
) {
const relationObjectMetadataNamePlural = targetObjectMetadata.namePlural;
const { description } = buildDescriptionForRelationFieldMetadataOnFromField(
{
relationObjectMetadataNamePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
},
);
return {
description,
};
}
private createTargetFieldMetadata(
workspaceId: string,
sourceObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'labelSingular' | 'id' | 'nameSingular'
>,
targetObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'namePlural' | 'labelSingular' | 'id' | 'nameSingular'
>,
): PartialRelationFieldMetadata {
const customStandardFieldId =
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom;
if (!customStandardFieldId) {
throw new Error(
`Custom standard field ID not found for ${targetObjectMetadata.nameSingular}`,
);
}
const { description } = buildDescriptionForRelationFieldMetadataOnToField({
relationObjectMetadataNamePlural: targetObjectMetadata.namePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
});
return {
id: uuidV4(),
name: sourceObjectMetadata.nameSingular,
label: sourceObjectMetadata.labelSingular,
description,
standardId: createRelationDeterministicUuid({
objectId: sourceObjectMetadata.id,
standardId: customStandardFieldId,
}),
objectMetadataId: targetObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
isSystem: true,
type: FieldMetadataType.RELATION,
icon: 'IconBuildingSkyscraper',
isNullable: true,
};
}
private updateTargetFieldMetadata(
sourceObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'labelSingular'
>,
targetObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'namePlural'
>,
) {
const customStandardFieldId =
// @ts-expect-error legacy noImplicitAny
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom;
if (!customStandardFieldId) {
throw new Error(
`Custom standard field ID not found for ${targetObjectMetadata.nameSingular}`,
);
}
const { description } = buildDescriptionForRelationFieldMetadataOnToField({
relationObjectMetadataNamePlural: targetObjectMetadata.namePlural,
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
});
return {
name: sourceObjectMetadata.nameSingular,
label: sourceObjectMetadata.labelSingular,
description,
};
}
private validateFieldMetadataTypeIsMorphRelation = (
fieldMetadatas: FieldMetadataEntity[],
): fieldMetadatas is Array<
FieldMetadataEntity & FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>
> => {
return fieldMetadatas.every(
(fieldMetadata) =>
fieldMetadata.type === FieldMetadataType.MORPH_RELATION,
);
};
public async findTargetMorphRelationFieldMetadatas(
objectMetadataId: string,
): Promise<FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>[]> {
const fieldMetadatas = await this.fieldMetadataRepository.find({
where: {
relationTargetObjectMetadataId: objectMetadataId,
type: FieldMetadataType.MORPH_RELATION,
},
relations: {
relationTargetObjectMetadata: true,
object: true,
},
});
if (!this.validateFieldMetadataTypeIsMorphRelation(fieldMetadatas)) {
throw new ObjectMetadataException(
'Invalid field metadata type. Expected MORPH_RELATION only',
ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT,
);
}
return fieldMetadatas;
}
// Not maintained on v1, this side effect is broken and will duplicated field name references on
// object update
// It's functional on v2
public async updateMorphRelationsJoinColumnName({
existingObjectMetadata,
objectMetadataForUpdate: _,
queryRunner,
}: {
existingObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'labelPlural' | 'icon' | 'fieldsById'
>;
objectMetadataForUpdate: Pick<
ObjectMetadataItemWithFieldMaps,
| 'nameSingular'
| 'isCustom'
| 'workspaceId'
| 'id'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'fieldsById'
>;
queryRunner: QueryRunner;
}): Promise<
{
fieldMetadata: FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>;
newJoinColumnName: string;
}[]
> {
const fieldMetadataRepository =
queryRunner.manager.getRepository(FieldMetadataEntity);
const morphRelationFieldMetadataTargets =
await this.findTargetMorphRelationFieldMetadatas(
existingObjectMetadata.id,
);
const morphRelationFieldMetadataToUpdate =
morphRelationFieldMetadataTargets.filter(
(morphRelationFieldMetadata) =>
morphRelationFieldMetadata.settings?.relationType ===
RelationType.MANY_TO_ONE,
);
const morphRelationFieldMetadataToUpdateWithNewJoinColumnName = [];
if (morphRelationFieldMetadataToUpdate.length > 0) {
for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) {
const newJoinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: morphRelationFieldMetadata.name,
});
await fieldMetadataRepository.save({
...morphRelationFieldMetadata,
settings: {
...morphRelationFieldMetadata.settings,
joinColumnName: newJoinColumnName,
},
});
morphRelationFieldMetadataToUpdateWithNewJoinColumnName.push({
fieldMetadata: morphRelationFieldMetadata,
newJoinColumnName,
});
}
}
return morphRelationFieldMetadataToUpdateWithNewJoinColumnName;
}
private validateFieldNameAvailabilityOrThrow({
name,
fieldMetadataMapById,
}: {
name: string;
fieldMetadataMapById: FieldMetadataMap;
}) {
try {
validateFieldNameAvailabilityOrThrow({
name,
fieldMetadataMapById,
});
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new ObjectMetadataException(
`Name "${name}" is not available.`,
ObjectMetadataExceptionCode.NAME_CONFLICT,
{
userFriendlyMessage: msg`Name "${name}" is not available.`,
},
);
}
throw error;
}
}
}
@@ -1,424 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { type QueryRunner, Repository } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildMigrationsForCustomObjectRelations } from 'src/engine/metadata-modules/object-metadata/utils/build-migrations-for-custom-object-relations.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
type WorkspaceMigrationColumnDrop,
type WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} 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 { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { RELATION_MIGRATION_PRIORITY_PREFIX } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
@Injectable()
export class ObjectMetadataMigrationService {
constructor(
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
) {}
public async createTableMigration(
createdObjectMetadata: ObjectMetadataEntity,
queryRunner?: QueryRunner,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`create-${createdObjectMetadata.nameSingular}`),
createdObjectMetadata.workspaceId,
[
{
name: computeObjectTargetTable(createdObjectMetadata),
action: WorkspaceMigrationTableActionType.CREATE,
} satisfies WorkspaceMigrationTableAction,
],
queryRunner,
);
}
public async createColumnsMigrations(
createdObjectMetadata: ObjectMetadataEntity,
fieldMetadataCollection: FieldMetadataEntity[],
queryRunner?: QueryRunner,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${createdObjectMetadata.nameSingular}-fields`,
),
createdObjectMetadata.workspaceId,
[
{
name: computeObjectTargetTable(createdObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: fieldMetadataCollection.flatMap((fieldMetadata) =>
this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
fieldMetadata,
),
),
},
],
queryRunner,
);
}
public async createRelationMigrations(
createdObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'workspaceId' | 'isCustom'
>,
relatedObjectMetadataCollection: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom'
>[],
queryRunner?: QueryRunner,
) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${createdObjectMetadata.nameSingular}-relations`,
),
createdObjectMetadata.workspaceId,
buildMigrationsForCustomObjectRelations(
createdObjectMetadata,
relatedObjectMetadataCollection,
),
queryRunner,
);
}
public async createRenameTableMigration(
existingObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom'
>,
objectMetadataForUpdate: Pick<
ObjectMetadataEntity,
'nameSingular' | 'isCustom'
>,
workspaceId: string,
queryRunner?: QueryRunner,
) {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`rename-${existingObjectMetadata.nameSingular}`),
workspaceId,
[
{
name: existingTargetTableName,
newName: newTargetTableName,
action: WorkspaceMigrationTableActionType.ALTER,
},
],
queryRunner,
);
}
public async updateRelationMigrations(
currentObjectMetadata: Pick<ObjectMetadataEntity, 'nameSingular'>,
alteredObjectMetadata: Pick<ObjectMetadataEntity, 'nameSingular'>,
relationMetadataCollection: {
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
targetFieldMetadata: FieldMetadataEntity;
sourceFieldMetadata: FieldMetadataEntity;
}[],
workspaceId: string,
queryRunner?: QueryRunner,
) {
for (const { targetObjectMetadata } of relationMetadataCollection) {
const targetTableName = computeObjectTargetTable(targetObjectMetadata);
const columnName = `${currentObjectMetadata.nameSingular}Id`;
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-${currentObjectMetadata.nameSingular}-to-${alteredObjectMetadata.nameSingular}-in-${targetObjectMetadata.nameSingular}`,
),
workspaceId,
[
{
name: targetTableName,
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${alteredObjectMetadata.nameSingular}Id`,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
},
],
},
],
queryRunner,
);
}
}
public async createUpdateForeignKeysMigrations(
existingObjectMetadata: ObjectMetadataEntity,
updatedObjectMetadata: ObjectMetadataEntity,
relationsAndForeignKeysMetadata: {
relatedObjectMetadata: ObjectMetadataEntity;
foreignKeyFieldMetadata: FieldMetadataEntity;
}[],
workspaceId: string,
queryRunner?: QueryRunner,
) {
for (const {
relatedObjectMetadata,
foreignKeyFieldMetadata,
} of relationsAndForeignKeysMetadata) {
const relatedObjectTableName = computeObjectTargetTable(
relatedObjectMetadata,
);
const columnName = `${existingObjectMetadata.nameSingular}Id`;
const columnType = fieldMetadataTypeToColumnType(
foreignKeyFieldMetadata.type,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-${existingObjectMetadata.nameSingular}-to-${updatedObjectMetadata.nameSingular}-in-${relatedObjectMetadata.nameSingular}`,
),
workspaceId,
[
{
name: relatedObjectTableName,
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName,
columnType,
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${updatedObjectMetadata.nameSingular}Id`,
columnType,
isNullable: true,
defaultValue: null,
},
},
],
},
],
queryRunner,
);
}
}
public async deleteAllRelationsAndDropTable(
objectMetadata: ObjectMetadataEntity,
workspaceId: string,
queryRunner?: QueryRunner,
) {
const relationFields = objectMetadata.fields.filter(
(field) =>
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION),
) as FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>[];
const relationFieldsToDelete = [
...relationFields,
...(relationFields.map(
(relation) => relation.relationTargetFieldMetadata,
) as FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>[]),
];
if (relationFieldsToDelete.length !== 0) {
await this.fieldMetadataRepository.delete(
relationFieldsToDelete.map((relation) => relation.id),
);
}
for (const relationToDelete of relationFieldsToDelete) {
if (
relationToDelete.settings?.relationType === RelationType.ONE_TO_MANY
) {
continue;
}
const joinColumnName = relationToDelete.settings?.joinColumnName;
if (!joinColumnName) {
throw new Error(
`Join column name is not set for relation field ${relationToDelete.name}`,
);
}
if (relationToDelete.type !== FieldMetadataType.MORPH_RELATION) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`delete-${RELATION_MIGRATION_PRIORITY_PREFIX}-${relationToDelete.name}`,
),
workspaceId,
[
{
name: computeTableName(
relationToDelete.object.nameSingular,
relationToDelete.object.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName: joinColumnName,
} satisfies WorkspaceMigrationColumnDrop,
],
},
],
queryRunner,
);
}
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`delete-${objectMetadata.nameSingular}`),
workspaceId,
[
{
name: computeObjectTargetTable(objectMetadata),
action: WorkspaceMigrationTableActionType.DROP,
},
],
queryRunner,
);
}
public async recomputeEnumNames(
updatedObjectMetadata: Pick<
ObjectMetadataItemWithFieldMaps,
'nameSingular' | 'isCustom' | 'id' | 'fieldsById'
>,
workspaceId: string,
queryRunner?: QueryRunner,
) {
const enumFieldMetadataTypes = [
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.RATING,
FieldMetadataType.ACTOR,
];
const fieldMetadataToUpdate = Object.values(
updatedObjectMetadata.fieldsById,
).filter((field) => enumFieldMetadataTypes.includes(field.type));
for (const fieldMetadata of fieldMetadataToUpdate) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${fieldMetadata.name}-enum-name`),
workspaceId,
[
{
name: computeTableName(
updatedObjectMetadata.nameSingular,
updatedObjectMetadata.isCustom,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.ALTER,
fieldMetadata,
fieldMetadata,
),
},
],
queryRunner,
);
}
}
public async updateMorphRelationMigrations({
workspaceId,
morphRelationFieldMetadataToUpdate,
queryRunner,
}: {
workspaceId: string;
morphRelationFieldMetadataToUpdate: {
fieldMetadata: FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>;
newJoinColumnName: string;
}[];
queryRunner?: QueryRunner;
}) {
for (const morphRelationFieldMetadata of morphRelationFieldMetadataToUpdate) {
if (!morphRelationFieldMetadata.fieldMetadata.settings?.joinColumnName) {
throw new ObjectMetadataException(
`Settings for morph relation field should be defined ${morphRelationFieldMetadata.fieldMetadata.name}`,
ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT,
);
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-join-column-name-${morphRelationFieldMetadata.fieldMetadata.name}-to-${morphRelationFieldMetadata.newJoinColumnName}-in-${morphRelationFieldMetadata.fieldMetadata.object.nameSingular}`,
),
workspaceId,
[
{
name: computeObjectTargetTable(
morphRelationFieldMetadata.fieldMetadata.object,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName:
morphRelationFieldMetadata.fieldMetadata.settings
?.joinColumnName,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${morphRelationFieldMetadata.newJoinColumnName}`,
columnType: 'uuid',
isNullable: true,
defaultValue: null,
},
},
],
},
],
queryRunner,
);
}
}
}
@@ -1,176 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/metadata-modules/view/enums/view-key.enum';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
import { type FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@Injectable()
export class ObjectMetadataRelatedRecordsService {
constructor(
private readonly viewService: ViewService,
private readonly viewFieldService: ViewFieldService,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
public async createObjectRelatedRecords(
objectMetadata: ObjectMetadataEntity,
) {
const view = await this.createView(objectMetadata);
await this.createViewFields(objectMetadata, view.id);
await this.createViewWorkspaceFavorite(objectMetadata.workspaceId, view.id);
await this.viewService.flushGraphQLCache(objectMetadata.workspaceId);
}
public async updateLabelMetadataIdentifierInObjectViews({
newLabelMetadataIdentifierFieldMetadata,
}: {
newLabelMetadataIdentifierFieldMetadata: FieldMetadataEntity;
}) {
const objectMetadataViews = await this.viewService.findByObjectMetadataId(
newLabelMetadataIdentifierFieldMetadata.workspaceId,
newLabelMetadataIdentifierFieldMetadata.objectMetadataId,
);
for (const view of objectMetadataViews) {
let labelMetadataIdentifierViewField = view.viewFields.find(
(viewField) =>
viewField.fieldMetadataId ===
newLabelMetadataIdentifierFieldMetadata.id,
);
const currentMinPositionAmongViewFields = this.getViewFieldsMinPosition(
view.viewFields,
);
if (!labelMetadataIdentifierViewField) {
await this.viewFieldService.create({
fieldMetadataId: newLabelMetadataIdentifierFieldMetadata.id,
position: currentMinPositionAmongViewFields - 1,
isVisible: true,
size: DEFAULT_VIEW_FIELD_SIZE,
viewId: view.id,
workspaceId: newLabelMetadataIdentifierFieldMetadata.workspaceId,
});
continue;
}
await this.viewFieldService.update(
labelMetadataIdentifierViewField.id,
newLabelMetadataIdentifierFieldMetadata.workspaceId,
{
position: currentMinPositionAmongViewFields - 1,
isVisible: true,
},
);
}
}
private getViewFieldsMinPosition(viewFields: ViewFieldEntity[]): number {
if (viewFields.length === 0) {
return 0;
}
return viewFields.reduce((min, field) => Math.min(min, field.position), 0);
}
private async createView(
objectMetadata: ObjectMetadataEntity,
): Promise<ViewDTO> {
return await this.viewService.create({
objectMetadataId: objectMetadata.id,
type: ViewType.TABLE,
name: `All ${objectMetadata.labelPlural}`,
key: ViewKey.INDEX,
icon: 'IconList',
workspaceId: objectMetadata.workspaceId,
});
}
private async createViewFields(
objectMetadata: ObjectMetadataEntity,
viewId: string,
): Promise<void> {
const viewFields = objectMetadata.fields
.filter((field) => field.name !== 'id' && field.name !== 'deletedAt')
.map((field, index) => ({
fieldMetadataId: field.id,
position: index,
isVisible: true,
size: DEFAULT_VIEW_FIELD_SIZE,
viewId: viewId,
workspaceId: objectMetadata.workspaceId,
}));
for (const viewField of viewFields) {
await this.viewFieldService.create(viewField);
}
}
public async createViewWorkspaceFavorite(
workspaceId: string,
viewId: string,
): Promise<void> {
const favoriteRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<FavoriteWorkspaceEntity>(
workspaceId,
'favorite',
);
const favoriteCount = await favoriteRepository.count();
await favoriteRepository.insert(
favoriteRepository.create({
viewId: viewId,
position: favoriteCount,
}),
);
}
public async updateObjectViews(
updatedObjectMetadata: Pick<
ObjectMetadataEntity,
'id' | 'labelPlural' | 'icon'
>,
workspaceId: string,
) {
await this.viewRepository.update(
{
objectMetadataId: updatedObjectMetadata.id,
key: ViewKey.INDEX,
workspaceId,
},
{
name: `All ${updatedObjectMetadata.labelPlural}`,
...(isDefined(updatedObjectMetadata.icon)
? { icon: updatedObjectMetadata.icon }
: {}),
},
);
}
public async deleteObjectViews(
objectMetadata: ObjectMetadataEntity,
workspaceId: string,
) {
await this.viewRepository.delete({
objectMetadataId: objectMetadata.id,
workspaceId,
});
}
}