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:
Jérémy M
2025-04-22 19:01:39 +02:00
committed by GitHub
parent de1489aabb
commit cc29c25176
160 changed files with 3247 additions and 711 deletions

View File

@ -1,12 +1,14 @@
import { WorkspaceMigrationIndexFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-index.factory';
import { WorkspaceMigrationObjectFactory } from './workspace-migration-object.factory';
import { WorkspaceMigrationFieldRelationFactory } from './workspace-migration-field-relation.factory';
import { WorkspaceMigrationFieldFactory } from './workspace-migration-field.factory';
import { WorkspaceMigrationObjectFactory } from './workspace-migration-object.factory';
import { WorkspaceMigrationRelationFactory } from './workspace-migration-relation.factory';
export const workspaceMigrationBuilderFactories = [
WorkspaceMigrationObjectFactory,
WorkspaceMigrationFieldFactory,
WorkspaceMigrationFieldRelationFactory,
WorkspaceMigrationRelationFactory,
WorkspaceMigrationIndexFactory,
];

View File

@ -0,0 +1,388 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.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 { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationEntity,
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 { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { FieldMetadataUpdate } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
import { camelCase } from 'src/utils/camel-case';
@Injectable()
export class WorkspaceMigrationFieldRelationFactory {
constructor(
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
) {}
async create(
originalObjectMetadataCollection: ObjectMetadataEntity[],
fieldMetadataCollection: FieldMetadataEntity<FieldMetadataType.RELATION>[],
action:
| WorkspaceMigrationBuilderAction.CREATE
| WorkspaceMigrationBuilderAction.DELETE,
): Promise<Partial<WorkspaceMigrationEntity>[]>;
async create(
originalObjectMetadataCollection: ObjectMetadataEntity[],
fieldMetadataUpdateCollection: FieldMetadataUpdate<FieldMetadataType.RELATION>[],
action: WorkspaceMigrationBuilderAction.UPDATE,
): Promise<Partial<WorkspaceMigrationEntity>[]>;
/**
* Deletion of the relation is handled by field deletion
*/
async create(
originalObjectMetadataCollection: ObjectMetadataEntity[],
fieldMetadataCollectionOrFieldMetadataUpdateCollection:
| FieldMetadataEntity<FieldMetadataType.RELATION>[]
| FieldMetadataUpdate<FieldMetadataType.RELATION>[],
action: WorkspaceMigrationBuilderAction,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const originalObjectMetadataMap = originalObjectMetadataCollection.reduce(
(result, currentObject) => {
result[currentObject.id] = currentObject;
return result;
},
{} as Record<string, ObjectMetadataEntity>,
);
switch (action) {
case WorkspaceMigrationBuilderAction.CREATE:
return this.createFieldRelationMigration(
originalObjectMetadataMap,
fieldMetadataCollectionOrFieldMetadataUpdateCollection as FieldMetadataEntity<FieldMetadataType.RELATION>[],
);
case WorkspaceMigrationBuilderAction.UPDATE:
return this.updateFieldRelationMigration(
originalObjectMetadataMap,
fieldMetadataCollectionOrFieldMetadataUpdateCollection as FieldMetadataUpdate<FieldMetadataType.RELATION>[],
);
case WorkspaceMigrationBuilderAction.DELETE:
return this.deleteFieldRelationMigration(
originalObjectMetadataMap,
fieldMetadataCollectionOrFieldMetadataUpdateCollection as FieldMetadataEntity<FieldMetadataType.RELATION>[],
);
default:
return [];
}
}
private async updateFieldRelationMigration(
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
fieldMetadataUpdateCollection: FieldMetadataUpdate<FieldMetadataType.RELATION>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const {
altered: sourceFieldMetadata,
} of fieldMetadataUpdateCollection) {
const sourceObjectMetadata =
originalObjectMetadataMap[sourceFieldMetadata.objectMetadataId];
const targetObjectMetadata =
originalObjectMetadataMap[
sourceFieldMetadata.relationTargetObjectMetadataId
];
if (!sourceObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${sourceFieldMetadata.objectMetadataId} not found`,
);
}
if (!targetObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${sourceFieldMetadata.relationTargetObjectMetadataId} not found`,
);
}
const targetFieldMetadata = targetObjectMetadata.fields.find(
(field) =>
field.id === sourceFieldMetadata.relationTargetFieldMetadataId,
);
if (!targetFieldMetadata) {
throw new Error(
`FieldMetadata with id ${sourceFieldMetadata.relationTargetFieldMetadataId} not found`,
);
}
if (
!isFieldMetadataEntityOfType(
targetFieldMetadata,
FieldMetadataType.RELATION,
)
) {
throw new Error(
`FieldMetadata with id ${sourceFieldMetadata.relationTargetFieldMetadataId} is not a relation`,
);
}
if (!targetFieldMetadata.settings) {
throw new Error(
`FieldMetadata for relation with id ${sourceFieldMetadata.id} has no settings`,
);
}
const migrations: WorkspaceMigrationTableAction[] = [
{
name: computeObjectTargetTable(targetObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY,
columnName: `${camelCase(targetFieldMetadata.name)}Id`,
},
],
},
{
name: computeObjectTargetTable(targetObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName: `${camelCase(targetFieldMetadata.name)}Id`,
referencedTableName:
computeObjectTargetTable(sourceObjectMetadata),
referencedTableColumnName: 'id',
isUnique:
targetFieldMetadata.settings.relationType ===
RelationType.ONE_TO_ONE,
onDelete: targetFieldMetadata.settings.onDelete,
},
],
},
];
workspaceMigrations.push({
workspaceId: sourceFieldMetadata.workspaceId,
name: generateMigrationName(
`update-relation-from-${sourceObjectMetadata.nameSingular}-to-${targetObjectMetadata.nameSingular}`,
),
isCustom: false,
migrations,
});
}
return workspaceMigrations;
}
private async createFieldRelationMigration(
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
fieldRelationMetadataCollection: FieldMetadataEntity<FieldMetadataType.RELATION>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const sourceFieldMetadata of fieldRelationMetadataCollection) {
const sourceObjectMetadata =
originalObjectMetadataMap[sourceFieldMetadata.objectMetadataId];
const targetObjectMetadata =
originalObjectMetadataMap[
sourceFieldMetadata.relationTargetObjectMetadataId
];
if (!sourceFieldMetadata.settings) {
throw new Error(
`FieldMetadata for relation with id ${sourceFieldMetadata.id} has no settings`,
);
}
// We're creating it from `ONE_TO_MANY` with the join column so we don't need to create a migration for `MANY_TO_ONE`
if (
sourceFieldMetadata.settings.relationType === RelationType.MANY_TO_ONE
) {
continue;
}
if (!sourceObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${sourceFieldMetadata.objectMetadataId} not found`,
);
}
if (!targetObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${sourceFieldMetadata.relationTargetObjectMetadataId} not found`,
);
}
const targetFieldMetadata = targetObjectMetadata.fields.find(
(field) =>
field.id === sourceFieldMetadata.relationTargetFieldMetadataId,
);
if (!targetFieldMetadata) {
throw new Error(
`FieldMetadata with id ${sourceFieldMetadata.relationTargetFieldMetadataId} not found`,
);
}
if (
!isFieldMetadataEntityOfType(
targetFieldMetadata,
FieldMetadataType.RELATION,
)
) {
throw new Error(
`FieldMetadata with id ${sourceFieldMetadata.relationTargetFieldMetadataId} is not a relation`,
);
}
if (!targetFieldMetadata.settings) {
throw new Error(
`FieldMetadata for relation with id ${sourceFieldMetadata.id} has no settings`,
);
}
if (!targetFieldMetadata.settings.joinColumnName) {
continue;
}
const migrations: WorkspaceMigrationTableAction[] = [
{
name: computeObjectTargetTable(targetObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
...this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
targetFieldMetadata,
),
],
},
{
name: computeObjectTargetTable(targetObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
columnName:
targetFieldMetadata.settings.joinColumnName ??
`${camelCase(targetFieldMetadata.name)}Id`,
referencedTableName:
computeObjectTargetTable(sourceObjectMetadata),
referencedTableColumnName: 'id',
isUnique:
targetFieldMetadata.settings.relationType ===
RelationType.ONE_TO_ONE,
onDelete: targetFieldMetadata.settings.onDelete,
},
],
},
];
workspaceMigrations.push({
workspaceId: sourceFieldMetadata.workspaceId,
name: generateMigrationName(
`create-relation-from-${sourceObjectMetadata.nameSingular}-to-${targetObjectMetadata.nameSingular}`,
),
isCustom: false,
migrations,
});
}
return workspaceMigrations;
}
private async deleteFieldRelationMigration(
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
fieldRelationMetadataCollection: FieldMetadataEntity<FieldMetadataType.RELATION>[],
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const sourceFieldMetadata of fieldRelationMetadataCollection) {
const sourceObjectMetadata =
originalObjectMetadataMap[sourceFieldMetadata.objectMetadataId];
const targetObjectMetadata =
originalObjectMetadataMap[
sourceFieldMetadata.relationTargetObjectMetadataId
];
if (!sourceObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${sourceFieldMetadata.objectMetadataId} not found`,
);
}
if (!targetObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${sourceFieldMetadata.relationTargetObjectMetadataId} not found`,
);
}
const targetFieldMetadata = targetObjectMetadata.fields.find(
(field) =>
field.id === sourceFieldMetadata.relationTargetFieldMetadataId,
);
if (!targetFieldMetadata) {
throw new Error(
`FieldMetadata with id ${sourceFieldMetadata.relationTargetFieldMetadataId} not found`,
);
}
if (
!isFieldMetadataEntityOfType(
targetFieldMetadata,
FieldMetadataType.RELATION,
)
) {
throw new Error(
`FieldMetadata with id ${sourceFieldMetadata.relationTargetFieldMetadataId} is not a relation`,
);
}
if (!targetFieldMetadata.settings) {
throw new Error(
`FieldMetadata for relation with id ${sourceFieldMetadata.id} has no settings`,
);
}
const migrations: WorkspaceMigrationTableAction[] = [
{
name: computeObjectTargetTable(targetObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY,
columnName: `${camelCase(targetFieldMetadata.name)}Id`,
},
],
},
{
name: computeObjectTargetTable(targetObjectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName:
targetFieldMetadata.settings.joinColumnName ??
`${camelCase(targetFieldMetadata.name)}Id`,
},
],
},
];
workspaceMigrations.push({
workspaceId: sourceFieldMetadata.workspaceId,
name: generateMigrationName(
`update-relation-from-${sourceObjectMetadata.nameSingular}-to-${targetObjectMetadata.nameSingular}`,
),
isCustom: false,
migrations,
});
}
return workspaceMigrations;
}
}

View File

@ -5,6 +5,8 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
@ -18,15 +20,18 @@ import {
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
export interface FieldMetadataUpdate {
current: FieldMetadataEntity;
altered: FieldMetadataEntity;
export interface FieldMetadataUpdate<
Type extends FieldMetadataType = FieldMetadataType,
> {
current: FieldMetadataEntity<Type>;
altered: FieldMetadataEntity<Type>;
}
@Injectable()
export class WorkspaceMigrationFieldFactory {
constructor(
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly featureFlagService: FeatureFlagService,
) {}
async create(
@ -86,6 +91,17 @@ export class WorkspaceMigrationFieldFactory {
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
if (fieldMetadataCollection.length === 0) {
return [];
}
const workspaceId = fieldMetadataCollection[0]?.workspaceId;
const isNewRelationEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsNewRelationEnabled,
workspaceId,
);
const fieldMetadataCollectionGroupByObjectMetadataId =
fieldMetadataCollection.reduce(
(result, currentFieldMetadata) => {
@ -110,7 +126,10 @@ export class WorkspaceMigrationFieldFactory {
for (const fieldMetadata of fieldMetadataCollection) {
// Relations are handled in workspace-migration-relation.factory.ts
if (fieldMetadata.type === FieldMetadataType.RELATION) {
if (
!isNewRelationEnabled &&
fieldMetadata.type === FieldMetadataType.RELATION
) {
continue;
}
@ -127,7 +146,7 @@ export class WorkspaceMigrationFieldFactory {
name: generateMigrationName(
`create-${objectMetadata.nameSingular}-fields`,
),
isCustom: false,
isCustom: objectMetadata.isCustom,
migrations: [
{
name: computeObjectTargetTable(
@ -149,9 +168,23 @@ export class WorkspaceMigrationFieldFactory {
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
if (fieldMetadataUpdateCollection.length === 0) {
return [];
}
const workspaceId = fieldMetadataUpdateCollection[0]?.current.workspaceId;
const isNewRelationEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsNewRelationEnabled,
workspaceId,
);
for (const fieldMetadataUpdate of fieldMetadataUpdateCollection) {
// Skip relations, because they're just representation and not real columns
if (fieldMetadataUpdate.altered.type === FieldMetadataType.RELATION) {
if (
!isNewRelationEnabled &&
fieldMetadataUpdate.altered.type === FieldMetadataType.RELATION
) {
continue;
}
@ -211,7 +244,7 @@ export class WorkspaceMigrationFieldFactory {
name: generateMigrationName(
`update-${fieldMetadataUpdate.altered.name}`,
),
isCustom: false,
isCustom: fieldMetadataUpdate.altered.isCustom,
migrations,
});
}
@ -250,7 +283,7 @@ export class WorkspaceMigrationFieldFactory {
workspaceMigrations.push({
workspaceId: fieldMetadata.workspaceId,
name: generateMigrationName(`delete-${fieldMetadata.name}`),
isCustom: false,
isCustom: fieldMetadata.isCustom,
migrations,
});
}

View File

@ -109,7 +109,7 @@ export class WorkspaceMigrationObjectFactory {
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
name: generateMigrationName(`create-${objectMetadata.nameSingular}`),
isCustom: false,
isCustom: objectMetadata.isCustom,
migrations,
});
}
@ -136,7 +136,7 @@ export class WorkspaceMigrationObjectFactory {
name: generateMigrationName(
`rename-${objectMetadataUpdate.current.nameSingular}`,
),
isCustom: false,
isCustom: objectMetadataUpdate.altered.isCustom,
migrations: [
{
name: oldTableName,
@ -167,7 +167,7 @@ export class WorkspaceMigrationObjectFactory {
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
name: generateMigrationName(`delete-${objectMetadata.nameSingular}`),
isCustom: false,
isCustom: objectMetadata.isCustom,
migrations: [
...(relationMetadataCollection ?? []).map(
(relationMetadata) =>

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { workspaceMigrationBuilderFactories } from './factories';
@Module({
imports: [WorkspaceMigrationModule],
imports: [WorkspaceMigrationModule, FeatureFlagModule],
providers: [...workspaceMigrationBuilderFactories],
exports: [...workspaceMigrationBuilderFactories],
})