feat: new relation sync-metadata, twenty-orm, create/update (#10217)
Fix https://github.com/twentyhq/core-team-issues/issues/330#issue-2827026606 and https://github.com/twentyhq/core-team-issues/issues/327#issue-2827001814 What this PR does when `isNewRelationEnabled` is set to `true`: - [x] Drop the creation of the foreign key as a `FieldMetadata` - [x] Stop creating `RelationMetadata` - [x] Properly fill `FieldMetadata` of type `RELATION` during the sync command - [x] Use new relation settings in TwentyORM - [x] Properly create `FieldMetadata` relations when we create a new object - [x] Handle `database:reset` with new relations --------- Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,311 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
import { 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
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 { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
|
||||
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';
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ObjectMetadataFieldRelationService {
|
||||
constructor(
|
||||
@InjectRepository(ObjectMetadataEntity, 'metadata')
|
||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||
@InjectRepository(FieldMetadataEntity, 'metadata')
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
) {}
|
||||
|
||||
public async createRelationsAndForeignKeysMetadata(
|
||||
workspaceId: string,
|
||||
sourceObjectMetadata: ObjectMetadataEntity,
|
||||
) {
|
||||
const relatedObjectMetadataCollection = await Promise.all(
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
|
||||
async (relationObjectMetadataStandardId) =>
|
||||
this.createRelationAndForeignKeyMetadata(
|
||||
workspaceId,
|
||||
sourceObjectMetadata,
|
||||
relationObjectMetadataStandardId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return relatedObjectMetadataCollection;
|
||||
}
|
||||
|
||||
private async createRelationAndForeignKeyMetadata(
|
||||
workspaceId: string,
|
||||
sourceObjectMetadata: ObjectMetadataEntity,
|
||||
relationObjectMetadataStandardId: string,
|
||||
) {
|
||||
const targetObjectMetadata =
|
||||
await this.objectMetadataRepository.findOneByOrFail({
|
||||
standardId: relationObjectMetadataStandardId,
|
||||
workspaceId: workspaceId,
|
||||
isCustom: false,
|
||||
});
|
||||
|
||||
await this.createFieldMetadataRelation(
|
||||
workspaceId,
|
||||
sourceObjectMetadata,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
|
||||
return targetObjectMetadata;
|
||||
}
|
||||
|
||||
private async createFieldMetadataRelation(
|
||||
workspaceId: string,
|
||||
sourceObjectMetadata: ObjectMetadataEntity,
|
||||
targetObjectMetadata: ObjectMetadataEntity,
|
||||
): Promise<FieldMetadataEntity<FieldMetadataType.RELATION>[]> {
|
||||
const sourceFieldMetadata = this.createSourceFieldMetadata(
|
||||
workspaceId,
|
||||
sourceObjectMetadata,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
|
||||
const targetFieldMetadata = this.createTargetFieldMetadata(
|
||||
workspaceId,
|
||||
sourceObjectMetadata,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
|
||||
return this.fieldMetadataRepository.save([
|
||||
{
|
||||
...sourceFieldMetadata,
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
},
|
||||
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: string,
|
||||
updatedObjectMetadata: ObjectMetadataEntity,
|
||||
): Promise<
|
||||
{
|
||||
targetObjectMetadata: ObjectMetadataEntity;
|
||||
targetFieldMetadata: FieldMetadataEntity;
|
||||
sourceFieldMetadata: FieldMetadataEntity;
|
||||
}[]
|
||||
> {
|
||||
return await Promise.all(
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
|
||||
async (relationObjectMetadataStandardId) =>
|
||||
this.updateRelationAndForeignKeyMetadata(
|
||||
workspaceId,
|
||||
updatedObjectMetadata,
|
||||
relationObjectMetadataStandardId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async updateRelationAndForeignKeyMetadata(
|
||||
workspaceId: string,
|
||||
sourceObjectMetadata: ObjectMetadataEntity,
|
||||
targetObjectMetadataStandardId: string,
|
||||
) {
|
||||
const targetObjectMetadata =
|
||||
await this.objectMetadataRepository.findOneByOrFail({
|
||||
standardId: targetObjectMetadataStandardId,
|
||||
workspaceId: workspaceId,
|
||||
isCustom: false,
|
||||
});
|
||||
|
||||
const targetFieldMetadataUpdateData = this.updateTargetFieldMetadata(
|
||||
sourceObjectMetadata,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
const targetFieldMetadataToUpdate =
|
||||
await this.fieldMetadataRepository.findOneByOrFail({
|
||||
standardId: createRelationDeterministicUuid({
|
||||
objectId: sourceObjectMetadata.id,
|
||||
standardId:
|
||||
STANDARD_OBJECT_FIELD_IDS[targetObjectMetadata.nameSingular].custom,
|
||||
}),
|
||||
objectMetadataId: targetObjectMetadata.id,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
const targetFieldMetadata = await this.fieldMetadataRepository.save({
|
||||
id: targetFieldMetadataToUpdate.id,
|
||||
...targetFieldMetadataUpdateData,
|
||||
});
|
||||
|
||||
const sourceFieldMetadataUpdateData = this.updateSourceFieldMetadata(
|
||||
sourceObjectMetadata,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
const sourceFieldMetadataToUpdate =
|
||||
await this.fieldMetadataRepository.findOneByOrFail({
|
||||
standardId:
|
||||
CUSTOM_OBJECT_STANDARD_FIELD_IDS[targetObjectMetadata.namePlural],
|
||||
objectMetadataId: sourceObjectMetadata.id,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
const sourceFieldMetadata = await this.fieldMetadataRepository.save({
|
||||
id: sourceFieldMetadataToUpdate.id,
|
||||
...sourceFieldMetadataUpdateData,
|
||||
});
|
||||
|
||||
return {
|
||||
targetObjectMetadata,
|
||||
targetFieldMetadata,
|
||||
sourceFieldMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
private createSourceFieldMetadata(
|
||||
workspaceId: string,
|
||||
sourceObjectMetadata: ObjectMetadataEntity,
|
||||
targetObjectMetadata: ObjectMetadataEntity,
|
||||
): Partial<FieldMetadataEntity<FieldMetadataType.RELATION>> {
|
||||
const relationObjectMetadataNamePlural = targetObjectMetadata.namePlural;
|
||||
|
||||
const { description } = buildDescriptionForRelationFieldMetadataOnFromField(
|
||||
{
|
||||
relationObjectMetadataNamePlural,
|
||||
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id: uuidV4(),
|
||||
standardId:
|
||||
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:
|
||||
STANDARD_OBJECT_ICONS[targetObjectMetadata.nameSingular] ||
|
||||
'IconBuildingSkyscraper',
|
||||
isNullable: true,
|
||||
};
|
||||
}
|
||||
|
||||
private updateSourceFieldMetadata(
|
||||
sourceObjectMetadata: ObjectMetadataEntity,
|
||||
targetObjectMetadata: ObjectMetadataEntity,
|
||||
) {
|
||||
const relationObjectMetadataNamePlural = targetObjectMetadata.namePlural;
|
||||
|
||||
const { description } = buildDescriptionForRelationFieldMetadataOnFromField(
|
||||
{
|
||||
relationObjectMetadataNamePlural,
|
||||
targetObjectLabelSingular: sourceObjectMetadata.labelSingular,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
private createTargetFieldMetadata(
|
||||
workspaceId: string,
|
||||
sourceObjectMetadata: ObjectMetadataEntity,
|
||||
targetObjectMetadata: ObjectMetadataEntity,
|
||||
): Partial<FieldMetadataEntity<FieldMetadataType.RELATION>> {
|
||||
const customStandardFieldId =
|
||||
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: ObjectMetadataEntity,
|
||||
targetObjectMetadata: ObjectMetadataEntity,
|
||||
) {
|
||||
const customStandardFieldId =
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { In, 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 { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
|
||||
@ -22,6 +24,7 @@ import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace
|
||||
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 { isFieldMetadataInterfaceOfType } 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()
|
||||
@ -117,6 +120,52 @@ export class ObjectMetadataMigrationService {
|
||||
);
|
||||
}
|
||||
|
||||
public async updateRelationMigrations(
|
||||
currentObjectMetadata: ObjectMetadataEntity,
|
||||
alteredObjectMetadata: ObjectMetadataEntity,
|
||||
relationMetadataCollection: {
|
||||
targetObjectMetadata: ObjectMetadataEntity;
|
||||
targetFieldMetadata: FieldMetadataEntity;
|
||||
sourceFieldMetadata: FieldMetadataEntity;
|
||||
}[],
|
||||
workspaceId: string,
|
||||
) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async createUpdateForeignKeysMigrations(
|
||||
existingObjectMetadata: ObjectMetadataEntity,
|
||||
updatedObjectMetadata: ObjectMetadataEntity,
|
||||
@ -173,15 +222,16 @@ export class ObjectMetadataMigrationService {
|
||||
public async deleteAllRelationsAndDropTable(
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
workspaceId: string,
|
||||
isNewRelationEnabled: boolean,
|
||||
) {
|
||||
const relationsToDelete: RelationToDelete[] = [];
|
||||
const relationsMetadataToDelete: RelationToDelete[] = [];
|
||||
|
||||
// TODO: Most of this logic should be moved to relation-metadata.service.ts
|
||||
for (const relation of [
|
||||
...objectMetadata.fromRelations,
|
||||
...objectMetadata.toRelations,
|
||||
]) {
|
||||
relationsToDelete.push({
|
||||
relationsMetadataToDelete.push({
|
||||
id: relation.id,
|
||||
fromFieldMetadataId: relation.fromFieldMetadata.id,
|
||||
toFieldMetadataId: relation.toFieldMetadata.id,
|
||||
@ -201,13 +251,13 @@ export class ObjectMetadataMigrationService {
|
||||
});
|
||||
}
|
||||
|
||||
if (relationsToDelete.length > 0) {
|
||||
if (relationsMetadataToDelete.length > 0) {
|
||||
await this.relationMetadataRepository.delete(
|
||||
relationsToDelete.map((relation) => relation.id),
|
||||
relationsMetadataToDelete.map((relation) => relation.id),
|
||||
);
|
||||
}
|
||||
|
||||
for (const relationToDelete of relationsToDelete) {
|
||||
for (const relationToDelete of relationsMetadataToDelete) {
|
||||
const foreignKeyFieldsToDelete = await this.fieldMetadataRepository.find({
|
||||
where: {
|
||||
name: `${relationToDelete.toFieldMetadataName}Id`,
|
||||
@ -254,6 +304,61 @@ export class ObjectMetadataMigrationService {
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewRelationEnabled) {
|
||||
const manyToOneRelationFieldsToDelete = objectMetadata.fields.filter(
|
||||
(field) =>
|
||||
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) &&
|
||||
(field as FieldMetadataEntity<FieldMetadataType.RELATION>).settings
|
||||
?.relationType === RelationType.MANY_TO_ONE,
|
||||
) as FieldMetadataEntity<FieldMetadataType.RELATION>[];
|
||||
|
||||
const oneToManyRelationFieldsToDelete = objectMetadata.fields.filter(
|
||||
(field) =>
|
||||
isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) &&
|
||||
(field as FieldMetadataEntity<FieldMetadataType.RELATION>).settings
|
||||
?.relationType === RelationType.ONE_TO_MANY,
|
||||
);
|
||||
|
||||
const relationFieldsToDelete = [
|
||||
...manyToOneRelationFieldsToDelete,
|
||||
...(oneToManyRelationFieldsToDelete.map(
|
||||
(field) => field.relationTargetFieldMetadata,
|
||||
) as FieldMetadataEntity<FieldMetadataType.RELATION>[]),
|
||||
];
|
||||
|
||||
for (const relationFieldToDelete of relationFieldsToDelete) {
|
||||
const joinColumnName = relationFieldToDelete.settings?.joinColumnName;
|
||||
|
||||
if (!joinColumnName) {
|
||||
throw new Error(
|
||||
`Join column name is not set for relation field ${relationFieldToDelete.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceMigrationService.createCustomMigration(
|
||||
generateMigrationName(
|
||||
`delete-${RELATION_MIGRATION_PRIORITY_PREFIX}-${relationFieldToDelete.name}`,
|
||||
),
|
||||
workspaceId,
|
||||
[
|
||||
{
|
||||
name: computeTableName(
|
||||
relationFieldToDelete.object.nameSingular,
|
||||
relationFieldToDelete.object.isCustom,
|
||||
),
|
||||
action: WorkspaceMigrationTableActionType.ALTER,
|
||||
columns: [
|
||||
{
|
||||
action: WorkspaceMigrationColumnActionType.DROP,
|
||||
columnName: joinColumnName,
|
||||
} satisfies WorkspaceMigrationColumnDrop,
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DROP TABLE
|
||||
await this.workspaceMigrationService.createCustomMigration(
|
||||
generateMigrationName(`delete-${objectMetadata.nameSingular}`),
|
||||
|
||||
Reference in New Issue
Block a user