From fce33004bce2e5466fe2ac1774dc06c5d8b0e22f Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:16:28 +0200 Subject: [PATCH] Connect logic in Workspace Entity Manager (#13078) Large PR, sorry for that. Don't hesitate to reach me to have full context (env. 500lines for integration and unit tests) - Add connect logic in Workspace Entity Manager - Update QueryDeepPartialEntity type to enable dev to use connect - Add integration test on createOne / createMany - Add unit test to cover main utils - Remove feature flag on connect closes https://github.com/twentyhq/core-team-issues/issues/1148 closes https://github.com/twentyhq/core-team-issues/issues/1147 --- .../src/generated-metadata/graphql.ts | 1 - .../twenty-front/src/generated/graphql.ts | 1 - ...nner-graphql-api-exception-handler.util.ts | 4 + .../input-type-definition.factory.ts | 3 - ...n-connect-input-type-definition.factory.ts | 5 - ...rkspace-build-schema-optionts.interface.ts | 6 - .../type-definitions.generator.ts | 12 - .../utils/generate-fields.utils.ts | 10 +- .../api/graphql/workspace-schema.factory.ts | 3 - .../enums/feature-flag-key.enum.ts | 1 - ...rtial-entity-with-relation-connect.type.ts | 26 ++ .../relation-connect-query-config.type.ts | 18 + .../workspace-entity-manager.ts | 140 ++++++- .../exceptions/twenty-orm.exception.ts | 11 +- .../repository/workspace.repository.ts | 40 +- ...elation-connect-query-configs.util.spec.ts | 349 ++++++++++++++++++ ...ate-sql-where-tuple-in-clause.util.spec.ts | 29 ++ .../get-record-to-connect-fields.util.spec.ts | 30 ++ ...ute-relation-connect-query-configs.util.ts | 344 +++++++++++++++++ .../create-sql-where-tuple-in-clause.utils.ts | 31 ++ .../twenty-orm/utils/format-data.util.ts | 2 +- ...get-associated-relation-field-name.util.ts | 2 + ...object-metadata-from-entity-target.util.ts | 49 +++ .../get-record-to-connect-fields.util.ts | 12 + ...-orm-graphql-api-exception-handler.util.ts | 21 ++ .../constants/test-company-ids.constants.ts | 1 + .../relation-connect.integration-spec.ts | 222 +++++++++++ 27 files changed, 1293 insertions(+), 80 deletions(-) create mode 100644 packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/__tests__/create-sql-where-tuple-in-clause.util.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-record-to-connect-fields.util.spec.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/get-associated-relation-field-name.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/get-record-to-connect-fields.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/object-generated/relation-connect.integration-spec.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 415dfdcea..7efd80bcd 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -683,7 +683,6 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', - IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED' diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 8de31c917..30c493ea3 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -647,7 +647,6 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', - IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED' diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts index 703ded201..10bb15945 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts @@ -13,6 +13,8 @@ import { RecordTransformerException } from 'src/engine/core-modules/record-trans import { recordTransformerGraphqlApiExceptionHandler } from 'src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util'; import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util'; +import { TwentyORMException } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { twentyORMGraphqlApiExceptionHandler } from 'src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util'; interface QueryFailedErrorWithCode extends QueryFailedError { code: string; @@ -44,6 +46,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = ( return workspaceExceptionHandler(error); case error instanceof GraphqlQueryRunnerException: return graphqlQueryRunnerExceptionHandler(error); + case error instanceof TwentyORMException: + return twentyORMGraphqlApiExceptionHandler(error); default: throw error; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts index b2cd38fcc..5f833a1e0 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts @@ -36,12 +36,10 @@ export class InputTypeDefinitionFactory { objectMetadata, kind, options, - isRelationConnectEnabled = false, }: { objectMetadata: ObjectMetadataInterface; kind: InputTypeDefinitionKind; options: WorkspaceBuildSchemaOptions; - isRelationConnectEnabled?: boolean; }): InputTypeDefinition { // @ts-expect-error legacy noImplicitAny const inputType = new GraphQLInputObjectType({ @@ -89,7 +87,6 @@ export class InputTypeDefinitionFactory { kind, options, typeFactory: this.inputTypeFactory, - isRelationConnectEnabled, }); } }, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts index f97449401..bb99ba7e1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory.ts @@ -39,11 +39,6 @@ export class RelationConnectInputTypeDefinitionFactory { kind: InputTypeDefinitionKind.Create, type: fields, }, - { - target, - kind: InputTypeDefinitionKind.Update, - type: fields, - }, ]; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts index 81852e9ca..19bd21e27 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface.ts @@ -13,10 +13,4 @@ export interface WorkspaceBuildSchemaOptions { * @default 'float' */ numberScalarMode?: NumberScalarMode; - - /** - * Workspace ID - used to relation connect feature flag check - * TODO: remove once IS_RELATION_CONNECT_ENABLED is removed - */ - workspaceId?: string; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts index 5a5c2a7a1..a3f156f4f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/type-definitions.generator.ts @@ -1,7 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { isDefined } from 'twenty-shared/utils'; - import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface'; import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface'; @@ -11,7 +9,6 @@ import { CompositeObjectTypeDefinitionFactory } from 'src/engine/api/graphql/wor import { EnumTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/enum-type-definition.factory'; import { ExtendObjectTypeDefinitionV2Factory } from 'src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory'; import { RelationConnectInputTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory'; -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 { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; @@ -213,13 +210,6 @@ export class TypeDefinitionsGenerator { objectMetadataCollection: ObjectMetadataInterface[], options: WorkspaceBuildSchemaOptions, ) { - const isRelationConnectEnabled = isDefined(options.workspaceId) - ? await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_RELATION_CONNECT_ENABLED, - options.workspaceId, - ) - : false; - const inputTypeDefs = objectMetadataCollection .map((objectMetadata) => { const optionalExtendedObjectMetadata = { @@ -236,13 +226,11 @@ export class TypeDefinitionsGenerator { objectMetadata, kind: InputTypeDefinitionKind.Create, options, - isRelationConnectEnabled, }), // Input type for update this.inputTypeDefinitionFactory.create({ objectMetadata: optionalExtendedObjectMetadata, kind: InputTypeDefinitionKind.Update, - isRelationConnectEnabled, options, }), // Filter input type diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts index 8423cabaa..2356e9747 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts @@ -47,13 +47,11 @@ export const generateFields = < kind, options, typeFactory, - isRelationConnectEnabled = false, }: { objectMetadata: ObjectMetadataInterface; kind: T; options: WorkspaceBuildSchemaOptions; typeFactory: TypeFactory; - isRelationConnectEnabled?: boolean; }): T extends InputTypeDefinitionKind ? GraphQLInputFieldConfigMap : // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -80,7 +78,6 @@ export const generateFields = < kind, options, typeFactory, - isRelationConnectEnabled, }); } else { generatedField = generateField({ @@ -168,7 +165,6 @@ const generateRelationField = < kind, options, typeFactory, - isRelationConnectEnabled, }: { fieldMetadata: FieldMetadataInterface< FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION @@ -176,7 +172,6 @@ const generateRelationField = < kind: T; options: WorkspaceBuildSchemaOptions; typeFactory: TypeFactory; - isRelationConnectEnabled: boolean; }) => { const relationField = {}; @@ -208,11 +203,10 @@ const generateRelationField = < }; if ( - [InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes( + [InputTypeDefinitionKind.Create].includes( kind as InputTypeDefinitionKind, ) && - isDefined(fieldMetadata.relationTargetObjectMetadataId) && - isRelationConnectEnabled + isDefined(fieldMetadata.relationTargetObjectMetadataId) ) { type = typeFactory.create( formatRelationConnectInputTarget( diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 46c32323f..95c124322 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -81,9 +81,6 @@ export class WorkspaceSchemaFactory { await this.workspaceGraphQLSchemaFactory.create( objectMetadataCollection, workspaceResolverBuilderMethodNames, - { - workspaceId: authContext.workspace.id, - }, ); usedScalarNames = diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 4560196ac..11ac737d5 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -8,6 +8,5 @@ export enum FeatureFlagKey { IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', - IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', } diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type.ts new file mode 100644 index 000000000..2f60e8323 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type.ts @@ -0,0 +1,26 @@ +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; + +export type ConnectWhereValue = string | Record; + +export type ConnectWhere = Record; + +export type ConnectObject = { + connect: { + where: ConnectWhere; + }; +}; + +type EntityRelationFields = { + [K in keyof T]: T[K] extends BaseWorkspaceEntity | null ? K : never; +}[keyof T]; + +export type QueryDeepPartialEntityWithRelationConnect = Omit< + QueryDeepPartialEntity, + EntityRelationFields +> & { + [K in keyof T]?: T[K] extends BaseWorkspaceEntity | null + ? T[K] | ConnectObject + : T[K]; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type.ts new file mode 100644 index 000000000..c0b69a86e --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type.ts @@ -0,0 +1,18 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; + +export type UniqueFieldCondition = [field: string, value: string]; + +export type UniqueConstraintCondition = UniqueFieldCondition[]; + +export type RelationConnectQueryConfig = { + targetObjectName: string; + recordToConnectConditions: UniqueConstraintCondition[]; + relationFieldName: string; + connectFieldName: string; + uniqueConstraintFields: FieldMetadataInterface[]; + recordToConnectConditionByEntityIndex: { + [entityIndex: number]: UniqueConstraintCondition; + }; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index df60d8547..e16e2a833 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -37,12 +37,22 @@ import { PermissionsExceptionCode, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; +import { QueryDeepPartialEntityWithRelationConnect } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type'; +import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; import { OperationType, validateOperationIsPermittedOrThrow, } from 'src/engine/twenty-orm/repository/permissions.utils'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util'; +import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils'; +import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util'; +import { getRecordToConnectFields } from 'src/engine/twenty-orm/utils/get-record-to-connect-fields.util'; type PermissionOptions = { shouldBypassPermissionChecks?: boolean; @@ -165,11 +175,21 @@ export class WorkspaceEntityManager extends EntityManager { ); } - override insert( + override async insert( target: EntityTarget, - entity: QueryDeepPartialEntity | QueryDeepPartialEntity[], + entity: + | QueryDeepPartialEntityWithRelationConnect + | QueryDeepPartialEntityWithRelationConnect[], permissionOptions?: PermissionOptions, ): Promise { + const entityArray = Array.isArray(entity) ? entity : [entity]; + + const connectedEntities = await this.processRelationConnect( + entityArray, + target, + permissionOptions, + ); + return this.createQueryBuilder( undefined, undefined, @@ -178,7 +198,7 @@ export class WorkspaceEntityManager extends EntityManager { ) .insert() .into(target) - .values(entity) + .values(connectedEntities) .execute(); } @@ -1321,4 +1341,118 @@ export class WorkspaceEntityManager extends EntityManager { PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED, ); } + + private async processRelationConnect( + entities: QueryDeepPartialEntityWithRelationConnect[], + target: EntityTarget, + permissionOptions?: PermissionOptions, + ): Promise[]> { + const objectMetadata = getObjectMetadataFromEntityTarget( + target, + this.internalContext, + ); + + const objectMetadataMap = this.internalContext.objectMetadataMaps; + + const relationConnectQueryConfigs = computeRelationConnectQueryConfigs( + entities, + objectMetadata, + objectMetadataMap, + ); + + if (!isDefined(relationConnectQueryConfigs)) return entities; + + const recordsToConnectWithConfig = await this.executeConnectQueries( + relationConnectQueryConfigs, + permissionOptions, + ); + + const updatedEntities = this.updateEntitiesWithRecordToConnectId( + entities, + recordsToConnectWithConfig, + ); + + return updatedEntities; + } + + private async executeConnectQueries( + relationConnectQueryConfigs: Record, + permissionOptions?: PermissionOptions, + ): Promise<[RelationConnectQueryConfig, Record[]][]> { + const AllRecordsToConnectWithConfig: [ + RelationConnectQueryConfig, + Record[], + ][] = []; + + for (const connectQueryConfig of Object.values( + relationConnectQueryConfigs, + )) { + const { clause, parameters } = createSqlWhereTupleInClause( + connectQueryConfig.recordToConnectConditions, + connectQueryConfig.targetObjectName, + ); + + const recordsToConnect = await this.createQueryBuilder( + connectQueryConfig.targetObjectName, + connectQueryConfig.targetObjectName, + undefined, + permissionOptions, + ) + .select(getRecordToConnectFields(connectQueryConfig)) + .where(clause, parameters) + .getRawMany(); + + AllRecordsToConnectWithConfig.push([ + connectQueryConfig, + recordsToConnect, + ]); + } + + return AllRecordsToConnectWithConfig; + } + + private updateEntitiesWithRecordToConnectId( + entities: QueryDeepPartialEntityWithRelationConnect[], + recordsToConnectWithConfig: [ + RelationConnectQueryConfig, + Record[], + ][], + ): QueryDeepPartialEntity[] { + return entities.map((entity, index) => { + for (const [ + connectQueryConfig, + recordsToConnect, + ] of recordsToConnectWithConfig) { + if ( + isDefined( + connectQueryConfig.recordToConnectConditionByEntityIndex[index], + ) + ) { + const recordToConnect = recordsToConnect.filter((record) => + connectQueryConfig.recordToConnectConditionByEntityIndex[ + index + ].every(([field, value]) => record[field] === value), + ); + + if (recordToConnect.length !== 1) { + const recordToConnectTotal = recordToConnect.length; + const connectFieldName = connectQueryConfig.connectFieldName; + + throw new TwentyORMException( + `Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal}.`, + TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND, + ); + } + + entity = { + ...entity, + [connectQueryConfig.relationFieldName]: recordToConnect[0]['id'], + [connectQueryConfig.connectFieldName]: null, + }; + } + } + + return entity; + }); + } } diff --git a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts index 103a7b0b5..d9e313240 100644 --- a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts +++ b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts @@ -1,8 +1,12 @@ import { CustomException } from 'src/utils/custom-exception'; export class TwentyORMException extends CustomException { - constructor(message: string, code: TwentyORMExceptionCode) { - super(message, code); + constructor( + message: string, + code: TwentyORMExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: string } = {}, + ) { + super(message, code, userFriendlyMessage); } } @@ -14,4 +18,7 @@ export enum TwentyORMExceptionCode { USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND = 'USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND', MALFORMED_METADATA = 'MALFORMED_METADATA', WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', + CONNECT_RECORD_NOT_FOUND = 'CONNECT_RECORD_NOT_FOUND', + CONNECT_NOT_ALLOWED = 'CONNECT_NOT_ALLOWED', + CONNECT_UNIQUE_CONSTRAINT_ERROR = 'CONNECT_UNIQUE_CONSTRAINT_ERROR', } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts index cd3bc6807..bb8a51c4f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -2,7 +2,6 @@ import { ObjectRecordsPermissions } from 'twenty-shared/types'; import { DeepPartial, DeleteResult, - EntitySchema, EntityTarget, FindManyOptions, FindOneOptions, @@ -28,12 +27,12 @@ import { PermissionsExceptionCode, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { QueryDeepPartialEntityWithRelationConnect } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type'; import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; -import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; +import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util'; export class WorkspaceRepository< T extends ObjectLiteral, @@ -552,7 +551,9 @@ export class WorkspaceRepository< * INSERT METHODS */ override async insert( - entity: QueryDeepPartialEntity | QueryDeepPartialEntity[], + entity: + | QueryDeepPartialEntityWithRelationConnect + | QueryDeepPartialEntityWithRelationConnect[], entityManager?: WorkspaceEntityManager, ): Promise { const manager = entityManager || this.manager; @@ -913,36 +914,7 @@ export class WorkspaceRepository< * PRIVATE METHODS */ private async getObjectMetadataFromTarget() { - const objectMetadataName = - typeof this.target === 'string' - ? this.target - : WorkspaceEntitiesStorage.getObjectMetadataName( - this.internalContext.workspaceId, - this.target as EntitySchema, - ); - - if (!objectMetadataName) { - throw new Error('Object metadata name is missing'); - } - - const objectMetadata = getObjectMetadataMapItemByNameSingular( - this.internalContext.objectMetadataMaps, - objectMetadataName, - ); - - if (!objectMetadata) { - throw new Error( - `Object metadata for object "${objectMetadataName}" is missing ` + - `in workspace "${this.internalContext.workspaceId}" ` + - `with object metadata collection length: ${ - Object.keys( - this.internalContext.objectMetadataMaps.idByNameSingular, - ).length - }`, - ); - } - - return objectMetadata; + return getObjectMetadataFromEntityTarget(this.target, this.internalContext); } private async transformOptions< diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts new file mode 100644 index 000000000..8c55f79a6 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts @@ -0,0 +1,349 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { 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 { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util'; + +describe('computeRelationConnectQueryConfigs', () => { + const personMetadata = { + id: 'person-object-metadata-id', + nameSingular: 'person', + fieldsById: { + 'person-id-field-id': { + id: 'person-id-field-id', + name: 'id', + type: FieldMetadataType.UUID, + label: 'id', + }, + 'person-name-field-id': { + id: 'person-name-field-id', + name: 'name', + type: FieldMetadataType.FULL_NAME, + label: 'name', + }, + 'person-company-1-field-id': { + id: 'person-company-1-field-id', + name: 'company-related-to-1', + type: FieldMetadataType.RELATION, + label: 'company-related-to-1', + relationTargetObjectMetadataId: 'company-object-metadata-id', + relationTargetFieldMetadataId: 'company-id-field-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + }, + }, + 'person-company-2-field-id': { + id: 'person-company-2-field-id', + name: 'company-related-to-2', + type: FieldMetadataType.RELATION, + label: 'company-related-to-2', + relationTargetObjectMetadataId: 'company-object-metadata-id', + relationTargetFieldMetadataId: 'company-id-field-id', + settings: { + relationType: RelationType.MANY_TO_ONE, + }, + }, + }, + fieldIdByName: { + id: 'person-id-field-id', + name: 'person-name-field-id', + 'company-related-to-1': 'person-company-1-field-id', + 'company-related-to-2': 'person-company-2-field-id', + }, + } as unknown as ObjectMetadataItemWithFieldMaps; + + const companyMetadata = { + id: 'company-object-metadata-id', + nameSingular: 'company', + indexMetadatas: [ + { + id: 'company-id-index-metadata-id', + name: 'company-id-index-metadata-name', + indexFieldMetadatas: [ + { + fieldMetadataId: 'company-id-field-id', + }, + ], + isUnique: true, + }, + { + id: 'company-domain-index-metadata-id', + name: 'company-domain-index-metadata-name', + indexFieldMetadatas: [ + { + fieldMetadataId: 'company-domain-name-field-id', + }, + ], + isUnique: true, + }, + { + id: 'company-composite-index-metadata-id', + name: 'company-composite-index-metadata-name', + indexFieldMetadatas: [ + { + fieldMetadataId: 'company-name-field-id', + }, + { + fieldMetadataId: 'company-description-field-id', + }, + ], + isUnique: true, + }, + ], + fieldsById: { + 'company-id-field-id': { + id: 'company-id-field-id', + name: 'id', + type: FieldMetadataType.UUID, + label: 'id', + }, + 'company-name-field-id': { + id: 'company-name-field-id', + name: 'name', + type: FieldMetadataType.TEXT, + label: 'name', + }, + 'company-description-field-id': { + id: 'company-description-field-id', + name: 'description', + type: FieldMetadataType.TEXT, + label: 'description', + }, + 'company-domain-name-field-id': { + id: 'company-domain-name-field-id', + name: 'domainName', + type: FieldMetadataType.LINKS, + label: 'domainName', + }, + 'company-address-field-id': { + id: 'company-address-field-id', + name: 'address', + type: FieldMetadataType.TEXT, + label: 'address', + }, + }, + fieldIdByName: { + id: 'company-id-field-id', + name: 'company-name-field-id', + description: 'company-description-field-id', + domainName: 'company-domain-name-field-id', + address: 'company-address-field-id', + }, + } as unknown as ObjectMetadataItemWithFieldMaps; + + const objectMetadataMaps = { + byId: { + 'person-object-metadata-id': personMetadata, + 'company-object-metadata-id': companyMetadata, + }, + idByNameSingular: { + person: 'person-object-metadata-id', + company: 'company-object-metadata-id', + }, + } as ObjectMetadataMaps; + + it('should return an empty object if no connect fields are found', () => { + const peopleEntityInputs = [ + { + id: '1', + name: { lastName: 'Doe', firstName: 'John' }, + }, + { + id: '2', + name: { lastName: 'Doe', firstName: 'Jane' }, + }, + ]; + + const result = computeRelationConnectQueryConfigs( + peopleEntityInputs, + personMetadata, + objectMetadataMaps, + ); + + expect(result).toEqual({}); + }); + + it('should throw an error if a connect field is not a relation field', () => { + const peopleEntityInputs = [ + { + id: '1', + name: { connect: { where: { name: { lastName: 'Doe' } } } }, + }, + { + id: '2', + }, + ]; + + expect(() => { + computeRelationConnectQueryConfigs( + peopleEntityInputs, + personMetadata, + objectMetadataMaps, + ); + }).toThrow('Connect is not allowed for name on person'); + }); + + it('should throw an error if connect field has not any unique constraint fully populated', () => { + const peopleEntityInputs = [ + { + id: '1', + 'company-related-to-1': { + connect: { where: { name: 'company1' } }, + }, + }, + ]; + + expect(() => { + computeRelationConnectQueryConfigs( + peopleEntityInputs, + personMetadata, + objectMetadataMaps, + ); + }).toThrow( + "Missing required fields: at least one unique constraint have to be fully populated for 'company-related-to-1'.", + ); + }); + + it('should throw an error if connect field are not in constraint fields', () => { + const peopleEntityInputs = [ + { + id: '1', + 'company-related-to-1': { + connect: { + where: { + domainName: { primaryLinkUrl: 'company1.com' }, + id: '1', + address: 'company1 address', + }, + }, + }, + }, + ]; + + expect(() => { + computeRelationConnectQueryConfigs( + peopleEntityInputs, + personMetadata, + objectMetadataMaps, + ); + }).toThrow( + "Field address is not a unique constraint field for 'company-related-to-1'.", + ); + }); + + it('should throw an error if connect field has different unique constraints populated', () => { + const peopleEntityInputs = [ + { + id: '1', + 'company-related-to-1': { + connect: { + where: { + domainName: { primaryLinkUrl: 'company1.com' }, + }, + }, + }, + }, + { + id: '2', + 'company-related-to-1': { + connect: { + where: { id: '2' }, + }, + }, + }, + ]; + + expect(() => { + computeRelationConnectQueryConfigs( + peopleEntityInputs, + personMetadata, + objectMetadataMaps, + ); + }).toThrow( + 'Expected the same constraint fields to be used consistently across all operations for company-related-to-1.', + ); + }); + + it('should return the correct relation connect query configs', () => { + const peopleEntityInputs = [ + { + id: '1', + 'company-related-to-1': { + connect: { + where: { + domainName: { primaryLinkUrl: 'company.com' }, + }, + }, + }, + 'company-related-to-2': { + connect: { + where: { id: '1' }, + }, + }, + }, + { + id: '2', + 'company-related-to-1': { + connect: { + where: { domainName: { primaryLinkUrl: 'other-company.com' } }, + }, + }, + 'company-related-to-2': { + connect: { + where: { id: '2' }, + }, + }, + }, + ]; + + const result = computeRelationConnectQueryConfigs( + peopleEntityInputs, + personMetadata, + objectMetadataMaps, + ); + + expect(result).toEqual({ + 'company-related-to-1': { + connectFieldName: 'company-related-to-1', + recordToConnectConditions: [ + [['domainNamePrimaryLinkUrl', 'company.com']], + [['domainNamePrimaryLinkUrl', 'other-company.com']], + ], + recordToConnectConditionByEntityIndex: { + '0': [['domainNamePrimaryLinkUrl', 'company.com']], + '1': [['domainNamePrimaryLinkUrl', 'other-company.com']], + }, + relationFieldName: 'company-related-to-1Id', + targetObjectName: 'company', + uniqueConstraintFields: [ + { + id: 'company-domain-name-field-id', + label: 'domainName', + name: 'domainName', + type: FieldMetadataType.LINKS, + }, + ], + }, + 'company-related-to-2': { + connectFieldName: 'company-related-to-2', + recordToConnectConditions: [[['id', '1']], [['id', '2']]], + recordToConnectConditionByEntityIndex: { + '0': [['id', '1']], + '1': [['id', '2']], + }, + relationFieldName: 'company-related-to-2Id', + targetObjectName: 'company', + uniqueConstraintFields: [ + { + id: 'company-id-field-id', + label: 'id', + name: 'id', + type: FieldMetadataType.UUID, + }, + ], + }, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/create-sql-where-tuple-in-clause.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/create-sql-where-tuple-in-clause.util.spec.ts new file mode 100644 index 000000000..fdf503736 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/create-sql-where-tuple-in-clause.util.spec.ts @@ -0,0 +1,29 @@ +import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils'; + +describe('createSqlWhereTupleInClause', () => { + it('should create a valid SQL WHERE clause for a tuple IN clause', () => { + const conditions = [ + [ + ['field1', 'value1'] as [string, string], + ['field2', 'value2'] as [string, string], + ], + [ + ['field1', 'value3'] as [string, string], + ['field2', 'value4'] as [string, string], + ], + ]; + const tableName = 'table_name'; + + const result = createSqlWhereTupleInClause(conditions, tableName); + + expect(result.clause).toBe( + '(table_name.field1, table_name.field2) IN ((:value0_0, :value0_1), (:value1_0, :value1_1))', + ); + expect(result.parameters).toEqual({ + value0_0: 'value1', + value0_1: 'value2', + value1_0: 'value3', + value1_1: 'value4', + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-record-to-connect-fields.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-record-to-connect-fields.util.spec.ts new file mode 100644 index 000000000..4109277aa --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-record-to-connect-fields.util.spec.ts @@ -0,0 +1,30 @@ +import { + RelationConnectQueryConfig, + UniqueConstraintCondition, +} from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type'; +import { getRecordToConnectFields } from 'src/engine/twenty-orm/utils/get-record-to-connect-fields.util'; + +describe('getRecordToConnectFields', () => { + it('should return the fields to connect', () => { + const connectQueryConfig = { + recordToConnectConditions: [ + [ + ['field1', 'value1'], + ['field2', 'value2'], + ] as UniqueConstraintCondition, + ], + targetObjectName: 'target', + relationFieldName: 'relationId', + connectFieldName: 'relation', + uniqueConstraintFields: [], + } as unknown as RelationConnectQueryConfig; + + const result = getRecordToConnectFields(connectQueryConfig); + + expect(result).toEqual([ + '"target"."id"', + '"target"."field1"', + '"target"."field2"', + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util.ts new file mode 100644 index 000000000..2c1707e0f --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util.ts @@ -0,0 +1,344 @@ +import { t } from '@lingui/core/macro'; +import deepEqual from 'deep-equal'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util'; +import { 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 { ConnectObject } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type'; +import { + RelationConnectQueryConfig, + UniqueConstraintCondition, +} from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { formatCompositeField } from 'src/engine/twenty-orm/utils/format-data.util'; +import { getAssociatedRelationFieldName } from 'src/engine/twenty-orm/utils/get-associated-relation-field-name.util'; +import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; + +export const computeRelationConnectQueryConfigs = ( + entities: Record[], + objectMetadata: ObjectMetadataItemWithFieldMaps, + objectMetadataMap: ObjectMetadataMaps, +) => { + const allConnectQueryConfigs: Record = {}; + + for (const [entityIndex, entity] of entities.entries()) { + const connectFields = extractConnectFields(entity); + + if (connectFields.length === 0) { + continue; + } + + for (const connectField of connectFields) { + const [connectFieldName, connectObject] = Object.entries(connectField)[0]; + + const { + recordToConnectCondition, + uniqueConstraintFields, + targetObjectNameSingular, + } = computeRecordToConnectCondition( + connectFieldName, + connectObject, + objectMetadata, + objectMetadataMap, + entity, + ); + + const connectQueryConfig = allConnectQueryConfigs[connectFieldName]; + + if (isDefined(connectQueryConfig)) { + checkUniqueConstraintsAreSameOrThrow( + connectQueryConfig, + uniqueConstraintFields, + ); + + allConnectQueryConfigs[connectFieldName] = updateConnectQueryConfigs( + connectQueryConfig, + recordToConnectCondition, + entityIndex, + ); + } else { + allConnectQueryConfigs[connectFieldName] = createConnectQueryConfig( + connectFieldName, + recordToConnectCondition, + uniqueConstraintFields, + targetObjectNameSingular, + entityIndex, + ); + } + } + } + + return allConnectQueryConfigs; +}; + +const updateConnectQueryConfigs = ( + connectQueryConfig: RelationConnectQueryConfig, + recordToConnectCondition: UniqueConstraintCondition, + entityIndex: number, +) => { + return { + ...connectQueryConfig, + recordToConnectConditions: [ + ...connectQueryConfig.recordToConnectConditions, + recordToConnectCondition, + ], + recordToConnectConditionByEntityIndex: { + ...connectQueryConfig.recordToConnectConditionByEntityIndex, + [entityIndex]: recordToConnectCondition, + }, + }; +}; + +const createConnectQueryConfig = ( + connectFieldName: string, + recordToConnectCondition: UniqueConstraintCondition, + uniqueConstraintFields: FieldMetadataInterface[], + targetObjectNameSingular: string, + entityIndex: number, +) => { + return { + targetObjectName: targetObjectNameSingular, + recordToConnectConditions: [recordToConnectCondition], + relationFieldName: getAssociatedRelationFieldName(connectFieldName), + connectFieldName, + uniqueConstraintFields, + recordToConnectConditionByEntityIndex: { + [entityIndex]: recordToConnectCondition, + }, + }; +}; + +const computeRecordToConnectCondition = ( + connectFieldName: string, + connectObject: ConnectObject, + objectMetadata: ObjectMetadataItemWithFieldMaps, + objectMetadataMap: ObjectMetadataMaps, + entity: Record, +): { + recordToConnectCondition: UniqueConstraintCondition; + uniqueConstraintFields: FieldMetadataInterface[]; + targetObjectNameSingular: string; +} => { + const field = + objectMetadata.fieldsById[objectMetadata.fieldIdByName[connectFieldName]]; + + if ( + !isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) || + field.settings?.relationType !== RelationType.MANY_TO_ONE + ) { + const objectMetadataNameSingular = objectMetadata.nameSingular; + + throw new TwentyORMException( + `Connect is not allowed for ${connectFieldName} on ${objectMetadata.nameSingular}`, + TwentyORMExceptionCode.CONNECT_NOT_ALLOWED, + { + userFriendlyMessage: t`Connect is not allowed for ${connectFieldName} on ${objectMetadataNameSingular}`, + }, + ); + } + checkNoRelationFieldConflictOrThrow(entity, connectFieldName); + + const targetObjectMetadata = + objectMetadataMap.byId[field.relationTargetObjectMetadataId || '']; + + if (!isDefined(targetObjectMetadata)) { + throw new TwentyORMException( + `Target object metadata not found for ${connectFieldName}`, + TwentyORMExceptionCode.MALFORMED_METADATA, + { + userFriendlyMessage: t`Target object metadata not found for ${connectFieldName}`, + }, + ); + } + + const uniqueConstraintFields = checkUniqueConstraintFullyPopulated( + targetObjectMetadata, + connectObject, + connectFieldName, + ); + + return { + recordToConnectCondition: computeUniqueConstraintCondition( + uniqueConstraintFields, + connectObject, + ), + uniqueConstraintFields, + targetObjectNameSingular: targetObjectMetadata.nameSingular, + }; +}; + +const extractConnectFields = ( + entity: Record, +): { [connectFieldName: string]: ConnectObject }[] => { + const connectFields: { [entityKey: string]: ConnectObject }[] = []; + + for (const [key, value] of Object.entries(entity)) { + if (hasRelationConnect(value)) { + connectFields.push({ [key]: value }); + } + } + + return connectFields; +}; + +const hasRelationConnect = (value: unknown): value is ConnectObject => { + if (!isDefined(value) || typeof value !== 'object') { + return false; + } + + const obj = value as Record; + + if (!isDefined(obj.connect) || typeof obj.connect !== 'object') { + return false; + } + + const connect = obj.connect as Record; + + if (!isDefined(connect.where) || typeof connect.where !== 'object') { + return false; + } + + const where = connect.where as Record; + + const whereKeys = Object.keys(where); + + if (whereKeys.length === 0) { + return false; + } + + return whereKeys.every((key) => { + const whereValue = where[key]; + + if (typeof whereValue === 'string') { + return true; + } + if (whereValue && typeof whereValue === 'object') { + const subObj = whereValue as Record; + + return Object.values(subObj).every( + (subValue) => typeof subValue === 'string', + ); + } + + return false; + }); +}; + +const checkUniqueConstraintFullyPopulated = ( + objectMetadata: ObjectMetadataItemWithFieldMaps, + connectObject: ConnectObject, + connectFieldName: string, +) => { + const uniqueConstraintsFields = getUniqueConstraintsFields({ + ...objectMetadata, + fields: Object.values(objectMetadata.fieldsById), + }); + + const hasUniqueConstraintFieldFullyPopulated = uniqueConstraintsFields.some( + (uniqueConstraintFields) => + uniqueConstraintFields.every((uniqueConstraintField) => + isDefined(connectObject.connect.where[uniqueConstraintField.name]), + ), + ); + + if (!hasUniqueConstraintFieldFullyPopulated) { + throw new TwentyORMException( + `Missing required fields: at least one unique constraint have to be fully populated for '${connectFieldName}'.`, + TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR, + { + userFriendlyMessage: t`Missing required fields: at least one unique constraint have to be fully populated for '${connectFieldName}'.`, + }, + ); + } + + return Object.keys(connectObject.connect.where).map((key) => { + const field = uniqueConstraintsFields + .flat() + .find((uniqueConstraintField) => uniqueConstraintField.name === key); + + if (!isDefined(field)) { + throw new TwentyORMException( + `Field ${key} is not a unique constraint field for '${connectFieldName}'.`, + TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR, + ); + } + + return field; + }); +}; + +const checkNoRelationFieldConflictOrThrow = ( + entity: Record, + fieldName: string, +) => { + const hasRelationFieldConflict = + isDefined(entity[fieldName]) && isDefined(entity[`${fieldName}Id`]); + + if (hasRelationFieldConflict) { + throw new TwentyORMException( + `${fieldName} and ${fieldName}Id cannot be both provided.`, + TwentyORMExceptionCode.CONNECT_NOT_ALLOWED, + { + userFriendlyMessage: t`${fieldName} and ${fieldName}Id cannot be both provided.`, + }, + ); + } +}; + +const computeUniqueConstraintCondition = ( + uniqueConstraintFields: FieldMetadataInterface[], + connectObject: ConnectObject, +): UniqueConstraintCondition => { + return uniqueConstraintFields.reduce((acc, uniqueConstraintField) => { + if (isCompositeFieldMetadataType(uniqueConstraintField.type)) { + return [ + ...acc, + ...Object.entries( + formatCompositeField( + connectObject.connect.where[uniqueConstraintField.name], + uniqueConstraintField, + ), + ), + ]; + } + + return [ + ...acc, + [ + uniqueConstraintField.name, + connectObject.connect.where[uniqueConstraintField.name], + ], + ]; + }, []); +}; + +const checkUniqueConstraintsAreSameOrThrow = ( + relationConnectQueryConfig: RelationConnectQueryConfig, + uniqueConstraintFields: FieldMetadataInterface[], +) => { + if ( + !deepEqual( + relationConnectQueryConfig.uniqueConstraintFields, + uniqueConstraintFields, + ) + ) { + const connectFieldName = relationConnectQueryConfig.connectFieldName; + + throw new TwentyORMException( + `Expected the same constraint fields to be used consistently across all operations for ${relationConnectQueryConfig.connectFieldName}.`, + TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR, + { + userFriendlyMessage: t`Expected the same constraint fields to be used consistently across all operations for ${connectFieldName}.`, + }, + ); + } +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils.ts b/packages/twenty-server/src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils.ts new file mode 100644 index 000000000..b8b4523ad --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils.ts @@ -0,0 +1,31 @@ +export const createSqlWhereTupleInClause = ( + conditions: [string, string][][], + tableName: string, +) => { + const fieldNames = conditions[0].map(([field, _]) => field); + + const tupleClause = fieldNames + .map((field) => `${tableName}.${field}`) + .join(', '); + const valuePlaceholders = conditions + .map((_, index) => { + const placeholders = fieldNames.map( + (_, fieldIndex) => `:value${index}_${fieldIndex}`, + ); + + return `(${placeholders.join(', ')})`; + }) + .join(', '); + + const clause = `(${tupleClause}) IN (${valuePlaceholders})`; + + const parameters: Record = {}; + + conditions.forEach((condition, conditionIndex) => { + condition.forEach(([_, value], fieldIndex) => { + parameters[`value${conditionIndex}_${fieldIndex}`] = value; + }); + }); + + return { clause, parameters }; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts index 9802a8dd8..47be7274c 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts @@ -54,7 +54,7 @@ export function formatData( return newData as T; } -function formatCompositeField( +export function formatCompositeField( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, fieldMetadata: FieldMetadataInterface, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-associated-relation-field-name.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-associated-relation-field-name.util.ts new file mode 100644 index 000000000..99ad66b7c --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-associated-relation-field-name.util.ts @@ -0,0 +1,2 @@ +export const getAssociatedRelationFieldName = (connectFieldName: string) => + `${connectFieldName}Id`; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util.ts new file mode 100644 index 000000000..10730c5f5 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util.ts @@ -0,0 +1,49 @@ +import { EntitySchema, EntityTarget, ObjectLiteral } from 'typeorm'; + +import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; + +import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage'; + +export const getObjectMetadataFromEntityTarget = ( + entityTarget: EntityTarget, + internalContext: WorkspaceInternalContext, +) => { + const objectMetadataName = + typeof entityTarget === 'string' + ? entityTarget + : WorkspaceEntitiesStorage.getObjectMetadataName( + internalContext.workspaceId, + entityTarget as EntitySchema, + ); + + if (!objectMetadataName) { + throw new TwentyORMException( + 'Object metadata name is missing', + TwentyORMExceptionCode.MALFORMED_METADATA, + ); + } + + const objectMetadata = getObjectMetadataMapItemByNameSingular( + internalContext.objectMetadataMaps, + objectMetadataName, + ); + + if (!objectMetadata) { + throw new TwentyORMException( + `Object metadata for object "${objectMetadataName}" is missing ` + + `in workspace "${internalContext.workspaceId}" ` + + `with object metadata collection length: ${ + Object.keys(internalContext.objectMetadataMaps.idByNameSingular) + .length + }`, + TwentyORMExceptionCode.MALFORMED_METADATA, + ); + } + + return objectMetadata; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-record-to-connect-fields.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-record-to-connect-fields.util.ts new file mode 100644 index 000000000..046cd1caf --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-record-to-connect-fields.util.ts @@ -0,0 +1,12 @@ +import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type'; + +export const getRecordToConnectFields = ( + connectQueryConfig: RelationConnectQueryConfig, +) => { + return [ + `"${connectQueryConfig.targetObjectName}"."id"`, + ...connectQueryConfig.recordToConnectConditions[0].map(([field]) => { + return `"${connectQueryConfig.targetObjectName}"."${field}"`; + }), + ]; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts new file mode 100644 index 000000000..7182150fd --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util.ts @@ -0,0 +1,21 @@ +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; + +export const twentyORMGraphqlApiExceptionHandler = ( + error: TwentyORMException, +) => { + switch (error.code) { + case TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND: + case TwentyORMExceptionCode.CONNECT_NOT_ALLOWED: + case TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + throw error; + } + } +}; diff --git a/packages/twenty-server/test/integration/constants/test-company-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-company-ids.constants.ts index 8111e961f..8c3d4c9a0 100644 --- a/packages/twenty-server/test/integration/constants/test-company-ids.constants.ts +++ b/packages/twenty-server/test/integration/constants/test-company-ids.constants.ts @@ -1 +1,2 @@ export const TEST_COMPANY_1_ID = '525c282e-030a-4a3e-90a0-d8aad0d33a93'; +export const TEST_COMPANY_2_ID = '2fd9a9ea-04a5-483b-846d-2819dd658fc1'; diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/relation-connect.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/relation-connect.integration-spec.ts new file mode 100644 index 000000000..7aee5a75e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/relation-connect.integration-spec.ts @@ -0,0 +1,222 @@ +import { + TEST_COMPANY_1_ID, + TEST_COMPANY_2_ID, +} from 'test/integration/constants/test-company-ids.constants'; +import { + TEST_PERSON_1_ID, + TEST_PERSON_2_ID, +} from 'test/integration/constants/test-person-ids.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +const PERSON_GQL_FIELDS_WITH_COMPANY = ` + id + company { + id + } +`; + +describe('relation connect in workspace createOne/createMany resolvers (e2e)', () => { + beforeAll(async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: `id`, + data: [ + { + id: TEST_COMPANY_1_ID, + domainName: { primaryLinkUrl: 'company1.com' }, + }, + { + id: TEST_COMPANY_2_ID, + domainName: { primaryLinkUrl: 'company2.com' }, + }, + ], + }); + + await makeGraphqlAPIRequest(graphqlOperation); + }); + + beforeEach(async () => { + await deleteAllRecords('person'); + }); + + afterAll(async () => { + await deleteAllRecords('company'); + await deleteAllRecords('person'); + }); + + it('should connect to other records through a MANY-TO-ONE relation - create One', async () => { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company1.com' } }, + }, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.createPerson).toBeDefined(); + expect(response.body.data.createPerson.id).toBe(TEST_PERSON_1_ID); + expect(response.body.data.createPerson.company.id).toBe(TEST_COMPANY_1_ID); + }); + + it('should connect to other records through a MANY-TO-ONE relation - create Many', async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: [ + { + id: TEST_PERSON_1_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company1.com' } }, + }, + }, + }, + { + id: TEST_PERSON_2_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company2.com' } }, + }, + }, + }, + ], + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.createPeople).toBeDefined(); + expect(response.body.data.createPeople).toHaveLength(2); + expect(response.body.data.createPeople[0].company.id).toBe( + TEST_COMPANY_1_ID, + ); + expect(response.body.data.createPeople[1].company.id).toBe( + TEST_COMPANY_2_ID, + ); + }); + + it('should throw an error if relation id field and relation connect field are both provided', async () => { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company1.com' } }, + }, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + 'company and companyId cannot be both provided.', + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); + + it('should throw an error if record to connect to does not exist', async () => { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'not-existing-company' } }, + }, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + 'Expected 1 record to connect to company, but found 0.', + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); + + it('should throw an error if unique constraint is not the same for all created records', async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: [ + { + id: TEST_PERSON_1_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company1.com' } }, + }, + }, + }, + { + id: TEST_PERSON_2_ID, + company: { + connect: { + where: { id: TEST_COMPANY_2_ID }, + }, + }, + }, + ], + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + 'Expected the same constraint fields to be used consistently across all operations for company.', + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); + + it('should throw an error if connect field is not set with field from unique constraint', async () => { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + company: { + connect: { + where: { name: 'company1' }, + }, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + 'Field "name" is not defined by type "CompanyWhereUniqueInput".', + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.BAD_USER_INPUT, + ); + }); +});