feat: manually implement joinColumn (#6022)

This PR introduce a new decorator named `@WorkspaceJoinColumn`, the goal
of this one is to manually declare the join columns inside the workspace
entities, so we don't have to rely on `ObjectRecord` type.

This decorator can be used that way:

```typescript
  @WorkspaceRelation({
    standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.company,
    type: RelationMetadataType.MANY_TO_ONE,
    label: 'Company',
    description: 'ActivityTarget company',
    icon: 'IconBuildingSkyscraper',
    inverseSideTarget: () => CompanyWorkspaceEntity,
    inverseSideFieldKey: 'activityTargets',
  })
  @WorkspaceIsNullable()
  company: Relation<CompanyWorkspaceEntity> | null;

  // The argument is the name of the relation above
  @WorkspaceJoinColumn('company')
  companyId: string | null;
```
This commit is contained in:
Jérémy M
2024-06-27 11:41:22 +02:00
committed by GitHub
parent 7eb69a78ef
commit 95c5602a4e
64 changed files with 427 additions and 243 deletions

View File

@ -0,0 +1,13 @@
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
export function WorkspaceJoinColumn(
relationPropertyKey: string,
): PropertyDecorator {
return (object, propertyKey) => {
metadataArgsStorage.addJoinColumns({
target: object.constructor,
relationName: relationPropertyKey,
joinColumn: propertyKey.toString(),
});
};
}

View File

@ -8,35 +8,19 @@ import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args
import { TypedReflect } from 'src/utils/typed-reflect';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
interface WorkspaceBaseRelationOptions<TType, TClass> {
interface WorkspaceRelationOptions<TClass> {
standardId: string;
label: string | ((objectMetadata: ObjectMetadataEntity) => string);
description?: string | ((objectMetadata: ObjectMetadataEntity) => string);
icon?: string;
type: TType;
type: RelationMetadataType;
inverseSideTarget: () => ObjectType<TClass>;
inverseSideFieldKey?: keyof TClass;
onDelete?: RelationOnDeleteAction;
}
export interface WorkspaceManyToOneRelationOptions<TClass>
extends WorkspaceBaseRelationOptions<
RelationMetadataType.MANY_TO_ONE | RelationMetadataType.ONE_TO_ONE,
TClass
> {
joinColumn?: string;
}
export interface WorkspaceOtherRelationOptions<TClass>
extends WorkspaceBaseRelationOptions<
RelationMetadataType.ONE_TO_MANY | RelationMetadataType.MANY_TO_MANY,
TClass
> {}
export function WorkspaceRelation<TClass extends object>(
options:
| WorkspaceManyToOneRelationOptions<TClass>
| WorkspaceOtherRelationOptions<TClass>,
options: WorkspaceRelationOptions<TClass>,
): PropertyDecorator {
return (object, propertyKey) => {
const isPrimary =
@ -63,14 +47,6 @@ export function WorkspaceRelation<TClass extends object>(
propertyKey.toString(),
);
let joinColumn: string | undefined;
if ('joinColumn' in options) {
joinColumn = options.joinColumn
? options.joinColumn
: `${propertyKey.toString()}Id`;
}
metadataArgsStorage.addRelations({
target: object.constructor,
standardId: options.standardId,
@ -82,7 +58,6 @@ export function WorkspaceRelation<TClass extends object>(
inverseSideTarget: options.inverseSideTarget,
inverseSideFieldKey: options.inverseSideFieldKey as string | undefined,
onDelete: options.onDelete,
joinColumn,
isPrimary,
isNullable,
isSystem,

View File

@ -4,6 +4,7 @@ import { ColumnType, EntitySchemaColumnOptions } from 'typeorm';
import { WorkspaceFieldMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-field-metadata-args.interface';
import { WorkspaceRelationMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface';
import { WorkspaceJoinColumnsMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-join-columns-metadata-args.interface';
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
@ -12,6 +13,7 @@ import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-me
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { compositeTypeDefintions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { getJoinColumn } from 'src/engine/twenty-orm/utils/get-join-column.util';
type EntitySchemaColumnMap = {
[key: string]: EntitySchemaColumnOptions;
@ -22,6 +24,7 @@ export class EntitySchemaColumnFactory {
create(
fieldMetadataArgsCollection: WorkspaceFieldMetadataArgs[],
relationMetadataArgsCollection: WorkspaceRelationMetadataArgs[],
joinColumnsMetadataArgsCollection: WorkspaceJoinColumnsMetadataArgs[],
): EntitySchemaColumnMap {
let entitySchemaColumnMap: EntitySchemaColumnMap = {};
@ -56,9 +59,14 @@ export class EntitySchemaColumnFactory {
};
for (const relationMetadataArgs of relationMetadataArgsCollection) {
if (relationMetadataArgs.joinColumn) {
entitySchemaColumnMap[relationMetadataArgs.joinColumn] = {
name: relationMetadataArgs.joinColumn,
const joinColumn = getJoinColumn(
joinColumnsMetadataArgsCollection,
relationMetadataArgs,
);
if (joinColumn) {
entitySchemaColumnMap[joinColumn] = {
name: joinColumn,
type: 'uuid',
nullable: relationMetadataArgs.isNullable,
};

View File

@ -4,9 +4,11 @@ import { EntitySchemaRelationOptions } from 'typeorm';
import { RelationType } from 'typeorm/metadata/types/RelationTypes';
import { WorkspaceRelationMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface';
import { WorkspaceJoinColumnsMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-join-columns-metadata-args.interface';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { getJoinColumn } from 'src/engine/twenty-orm/utils/get-join-column.util';
type EntitySchemaRelationMap = {
[key: string]: EntitySchemaRelationOptions;
@ -18,6 +20,7 @@ export class EntitySchemaRelationFactory {
// eslint-disable-next-line @typescript-eslint/ban-types
target: Function,
relationMetadataArgsCollection: WorkspaceRelationMetadataArgs[],
joinColumnsMetadataArgsCollection: WorkspaceJoinColumnsMetadataArgs[],
): EntitySchemaRelationMap {
const entitySchemaRelationMap: EntitySchemaRelationMap = {};
@ -27,16 +30,19 @@ export class EntitySchemaRelationFactory {
const oppositeObjectName = convertClassNameToObjectMetadataName(
oppositeTarget.name,
);
const relationType = this.getRelationType(relationMetadataArgs);
const joinColumn = getJoinColumn(
joinColumnsMetadataArgsCollection,
relationMetadataArgs,
);
entitySchemaRelationMap[relationMetadataArgs.name] = {
type: relationType,
target: oppositeObjectName,
inverseSide: relationMetadataArgs.inverseSideFieldKey ?? objectName,
joinColumn: relationMetadataArgs.joinColumn
joinColumn: joinColumn
? {
name: relationMetadataArgs.joinColumn,
name: joinColumn,
}
: undefined,
};

View File

@ -23,17 +23,21 @@ export class EntitySchemaFactory {
const fieldMetadataArgsCollection =
metadataArgsStorage.filterFields(target);
const joinColumnsMetadataArgsCollection =
metadataArgsStorage.filterJoinColumns(target);
const relationMetadataArgsCollection =
metadataArgsStorage.filterRelations(target);
const columns = this.entitySchemaColumnFactory.create(
fieldMetadataArgsCollection,
relationMetadataArgsCollection,
joinColumnsMetadataArgsCollection,
);
const relations = this.entitySchemaRelationFactory.create(
target,
relationMetadataArgsCollection,
joinColumnsMetadataArgsCollection,
);
const entitySchema = new EntitySchema({

View File

@ -0,0 +1,17 @@
export interface WorkspaceJoinColumnsMetadataArgs {
/**
* Class to which relation is applied.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
readonly target: Function;
/**
* Relation name.
*/
readonly relationName: string;
/**
* Relation label.
*/
readonly joinColumn: string;
}

View File

@ -62,11 +62,6 @@ export interface WorkspaceRelationMetadataArgs {
*/
readonly onDelete?: RelationOnDeleteAction;
/**
* Relation join column.
*/
readonly joinColumn?: string;
/**
* Is primary field.
*/

View File

@ -6,6 +6,7 @@ import { WorkspaceEntityMetadataArgs } from 'src/engine/twenty-orm/interfaces/wo
import { WorkspaceRelationMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface';
import { WorkspaceExtendedEntityMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-extended-entity-metadata-args.interface';
import { WorkspaceIndexMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface';
import { WorkspaceJoinColumnsMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-join-columns-metadata-args.interface';
export class MetadataArgsStorage {
private readonly entities: WorkspaceEntityMetadataArgs[] = [];
@ -15,6 +16,7 @@ export class MetadataArgsStorage {
private readonly dynamicRelations: WorkspaceDynamicRelationMetadataArgs[] =
[];
private readonly indexes: WorkspaceIndexMetadataArgs[] = [];
private readonly joinColumns: WorkspaceJoinColumnsMetadataArgs[] = [];
addEntities(...entities: WorkspaceEntityMetadataArgs[]): void {
this.entities.push(...entities);
@ -44,6 +46,10 @@ export class MetadataArgsStorage {
this.dynamicRelations.push(...dynamicRelations);
}
addJoinColumns(...joinColumns: WorkspaceJoinColumnsMetadataArgs[]): void {
this.joinColumns.push(...joinColumns);
}
filterEntities(
target: Function | string,
): WorkspaceEntityMetadataArgs | undefined;
@ -123,6 +129,20 @@ export class MetadataArgsStorage {
return this.filterByTarget(this.dynamicRelations, target);
}
filterJoinColumns(
target: Function | string,
): WorkspaceJoinColumnsMetadataArgs[];
filterJoinColumns(
target: (Function | string)[],
): WorkspaceJoinColumnsMetadataArgs[];
filterJoinColumns(
target: (Function | string) | (Function | string)[],
): WorkspaceJoinColumnsMetadataArgs[] {
return this.filterByTarget(this.joinColumns, target);
}
protected filterByTarget<T extends { target: Function | string }>(
array: T[],
target: (Function | string) | (Function | string)[],

View File

@ -0,0 +1,87 @@
import { WorkspaceJoinColumnsMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-join-columns-metadata-args.interface';
import { WorkspaceRelationMetadataArgs } from 'src/engine/twenty-orm/interfaces/workspace-relation-metadata-args.interface';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
export const getJoinColumn = (
joinColumnsMetadataArgsCollection: WorkspaceJoinColumnsMetadataArgs[],
relationMetadataArgs: WorkspaceRelationMetadataArgs,
opposite = false,
): string | null => {
if (
relationMetadataArgs.type === RelationMetadataType.ONE_TO_MANY ||
relationMetadataArgs.type === RelationMetadataType.MANY_TO_MANY
) {
return null;
}
const inverseSideTarget = relationMetadataArgs.inverseSideTarget();
const inverseSideJoinColumnsMetadataArgsCollection =
metadataArgsStorage.filterJoinColumns(inverseSideTarget);
const filteredJoinColumnsMetadataArgsCollection =
joinColumnsMetadataArgsCollection.filter(
(joinColumnsMetadataArgs) =>
joinColumnsMetadataArgs.relationName === relationMetadataArgs.name,
);
const oppositeFilteredJoinColumnsMetadataArgsCollection =
inverseSideJoinColumnsMetadataArgsCollection.filter(
(joinColumnsMetadataArgs) =>
joinColumnsMetadataArgs.relationName === relationMetadataArgs.name,
);
if (
filteredJoinColumnsMetadataArgsCollection.length > 0 &&
oppositeFilteredJoinColumnsMetadataArgsCollection.length > 0
) {
throw new Error(
`Join column for ${relationMetadataArgs.name} relation is present on both sides`,
);
}
// If we're in a ONE_TO_ONE relation and there are no join columns, we need to find the join column on the inverse side
if (
relationMetadataArgs.type === RelationMetadataType.ONE_TO_ONE &&
filteredJoinColumnsMetadataArgsCollection.length === 0 &&
!opposite
) {
const inverseSideRelationMetadataArgsCollection =
metadataArgsStorage.filterRelations(inverseSideTarget);
const inverseSideRelationMetadataArgs =
inverseSideRelationMetadataArgsCollection.find(
(inverseSideRelationMetadataArgs) =>
inverseSideRelationMetadataArgs.inverseSideFieldKey ===
relationMetadataArgs.name,
);
if (!inverseSideRelationMetadataArgs) {
throw new Error(
`Inverse side join column of relation ${relationMetadataArgs.name} is missing`,
);
}
return getJoinColumn(
inverseSideJoinColumnsMetadataArgsCollection,
inverseSideRelationMetadataArgs,
// Avoid infinite recursion
true,
);
}
// Check if there are multiple join columns for the relation
if (filteredJoinColumnsMetadataArgsCollection.length > 1) {
throw new Error(
`Multiple join columns found for relation ${relationMetadataArgs.name}`,
);
}
const joinColumnsMetadataArgs = filteredJoinColumnsMetadataArgsCollection[0];
if (!joinColumnsMetadataArgs) {
throw new Error(
`Join column is missing for relation ${relationMetadataArgs.name}`,
);
}
return joinColumnsMetadataArgs.joinColumn;
};