From 88a6913217d1313e049a908b52f810cf112de0f6 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:04:38 +0200 Subject: [PATCH] Connect/Disconnect - Add Disconnect logic + Migration to query builders (insert/update) (#13271) Context : Large PR with 600+ test files. Enable connect and disconnect logic in createMany (upsert true) / updateOne / updateMany resolvers - Add disconnect logic - Gather disconnect and connect logic -> called relation nested queries - Move logic to query builder (insert and update one) with a preparation step in .set/.values and an execution step in .execute - Add integration tests Test : - Test API call on updateMany, updateOne, createMany (upsert:true) with connect/disconnect --- ...phql-query-create-many-resolver.service.ts | 1 + ...n-connect-input-type-definition.factory.ts | 15 +- .../utils/generate-fields.utils.ts | 2 +- ...rtial-entity-with-relation-connect.type.ts | 13 +- ...ested-query-fields-by-entity-index.type.ts | 14 + .../workspace-entity-manager.ts | 138 +---- .../relation-nested-queries.ts | 249 ++++++++ .../workspace-insert-query-builder.ts | 42 +- .../workspace-update-query-builder.ts | 63 +- .../repository/workspace.repository.ts | 6 +- ...elation-connect-query-configs.util.spec.ts | 84 ++- ...ate-sql-where-tuple-in-clause.util.spec.ts | 2 +- ...ute-relation-connect-query-configs.util.ts | 74 +-- .../create-sql-where-tuple-in-clause.utils.ts | 2 +- ...ed-relation-fields-by-entity-index.util.ts | 135 +++++ ...ested-relation-queries.integration-spec.ts | 552 ++++++++++++++++++ .../relation-connect.integration-spec.ts | 222 ------- .../create-many-operation-factory.util.ts | 7 +- .../constants/RelationNestedQueriesKeyword.ts | 5 + packages/twenty-shared/src/constants/index.ts | 1 + 20 files changed, 1182 insertions(+), 445 deletions(-) create mode 100644 packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/relation-nested-queries.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/extract-nested-relation-fields-by-entity-index.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/object-generated/nested-relation-queries.integration-spec.ts delete mode 100644 packages/twenty-server/test/integration/graphql/suites/object-generated/relation-connect.integration-spec.ts create mode 100644 packages/twenty-shared/src/constants/RelationNestedQueriesKeyword.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts index ed415b0bc..7dcb9acb9 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts @@ -275,6 +275,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol const existingRec = existingRecords.find( (existingRecord) => + isDefined(existingRecord[field.column]) && existingRecord[field.column] === requestFieldValue, ); 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 8db05876d..a5325802d 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 @@ -1,11 +1,13 @@ import { Injectable } from '@nestjs/common'; import { + GraphQLBoolean, GraphQLInputFieldConfig, GraphQLInputObjectType, GraphQLInputType, GraphQLString, } from 'graphql'; +import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants'; import { InputTypeDefinition, @@ -36,6 +38,11 @@ export class RelationConnectInputTypeDefinitionFactory { kind: InputTypeDefinitionKind.Create, type: fields, }, + { + target, + kind: InputTypeDefinitionKind.Update, + type: fields, + }, ]; } @@ -45,13 +52,17 @@ export class RelationConnectInputTypeDefinitionFactory { return new GraphQLInputObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}RelationInput`, fields: () => ({ - connect: { + [RELATION_NESTED_QUERY_KEYWORDS.CONNECT]: { type: new GraphQLInputObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}ConnectInput`, fields: this.generateRelationWhereInputType(objectMetadata), }), description: `Connect to a ${objectMetadata.nameSingular} record`, }, + [RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT]: { + type: GraphQLBoolean, + description: `Disconnect from a ${objectMetadata.nameSingular} record`, + }, }), }); } @@ -127,7 +138,7 @@ export class RelationConnectInputTypeDefinitionFactory { }); return { - where: { + [RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE]: { type: new GraphQLInputObjectType({ name: `${pascalCase(objectMetadata.nameSingular)}WhereUniqueInput`, fields: () => fields, 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 e085166ba..664dc041c 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 @@ -200,7 +200,7 @@ const generateRelationField = < }; if ( - [InputTypeDefinitionKind.Create].includes( + [InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes( kind as InputTypeDefinitionKind, ) && isDefined(fieldMetadata.relationTargetObjectMetadataId) 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 index 2f60e8323..15d1c1a1b 100644 --- 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 @@ -1,3 +1,4 @@ +import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; @@ -7,20 +8,24 @@ export type ConnectWhereValue = string | Record; export type ConnectWhere = Record; export type ConnectObject = { - connect: { - where: ConnectWhere; + [RELATION_NESTED_QUERY_KEYWORDS.CONNECT]: { + [RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE]: ConnectWhere; }; }; +export type DisconnectObject = { + [RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT]: true; +}; + type EntityRelationFields = { [K in keyof T]: T[K] extends BaseWorkspaceEntity | null ? K : never; }[keyof T]; -export type QueryDeepPartialEntityWithRelationConnect = Omit< +export type QueryDeepPartialEntityWithNestedRelationFields = Omit< QueryDeepPartialEntity, EntityRelationFields > & { [K in keyof T]?: T[K] extends BaseWorkspaceEntity | null - ? T[K] | ConnectObject + ? T[K] | ConnectObject | DisconnectObject : T[K]; }; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type.ts new file mode 100644 index 000000000..c06ade3dc --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type.ts @@ -0,0 +1,14 @@ +import { + ConnectObject, + DisconnectObject, +} from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type'; + +export type RelationConnectQueryFieldsByEntityIndex = { + [entityIndex: string]: { [key: string]: ConnectObject }; +}; + +export type RelationDisconnectQueryFieldsByEntityIndex = { + [entityIndex: string]: { + [key: string]: DisconnectObject; + }; +}; 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 927a00fe2..e5aa0011b 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 @@ -40,24 +40,16 @@ 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 { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type'; 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 { 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'; -import { getRecordToConnectFields } from 'src/engine/twenty-orm/utils/get-record-to-connect-fields.util'; type PermissionOptions = { shouldBypassPermissionChecks?: boolean; @@ -172,19 +164,11 @@ export class WorkspaceEntityManager extends EntityManager { override async insert( target: EntityTarget, entity: - | QueryDeepPartialEntityWithRelationConnect - | QueryDeepPartialEntityWithRelationConnect[], + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[], selectedColumns: string[] = [], permissionOptions?: PermissionOptions, ): Promise { - const entityArray = Array.isArray(entity) ? entity : [entity]; - - const connectedEntities = await this.processRelationConnect( - entityArray, - target, - permissionOptions, - ); - return this.createQueryBuilder( undefined, undefined, @@ -193,7 +177,7 @@ export class WorkspaceEntityManager extends EntityManager { ) .insert() .into(target) - .values(connectedEntities) + .values(entity) .returning(selectedColumns) .execute(); } @@ -1484,118 +1468,4 @@ 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/relation-nested-queries/relation-nested-queries.ts b/packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/relation-nested-queries.ts new file mode 100644 index 000000000..f2d988ea8 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/relation-nested-queries.ts @@ -0,0 +1,249 @@ +import { isDefined } from 'class-validator'; +import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants'; +import { EntityTarget, ObjectLiteral } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; + +import { QueryDeepPartialEntityWithNestedRelationFields } 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 { + RelationConnectQueryFieldsByEntityIndex, + RelationDisconnectQueryFieldsByEntityIndex, +} from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; +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 { extractNestedRelationFieldsByEntityIndex } from 'src/engine/twenty-orm/utils/extract-nested-relation-fields-by-entity-index.util'; +import { getAssociatedRelationFieldName } from 'src/engine/twenty-orm/utils/get-associated-relation-field-name.util'; +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'; + +export class RelationNestedQueries { + private readonly internalContext: WorkspaceInternalContext; + + constructor(internalContext: WorkspaceInternalContext) { + this.internalContext = internalContext; + } + + prepareNestedRelationQueries( + entities: + | QueryDeepPartialEntityWithNestedRelationFields[] + | QueryDeepPartialEntityWithNestedRelationFields, + target: EntityTarget, + ): [ + RelationConnectQueryConfig[], + RelationDisconnectQueryFieldsByEntityIndex, + ] { + const entitiesArray = Array.isArray(entities) ? entities : [entities]; + + const { + relationConnectQueryFieldsByEntityIndex, + relationDisconnectQueryFieldsByEntityIndex, + } = extractNestedRelationFieldsByEntityIndex(entitiesArray); + + const connectConfig = this.prepareRelationConnect( + entitiesArray, + target, + relationConnectQueryFieldsByEntityIndex, + ); + + return [connectConfig, relationDisconnectQueryFieldsByEntityIndex]; + } + + private prepareRelationConnect( + entities: QueryDeepPartialEntityWithNestedRelationFields[], + target: EntityTarget, + relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex, + ) { + const objectMetadata = getObjectMetadataFromEntityTarget( + target, + this.internalContext, + ); + + const objectMetadataMap = this.internalContext.objectMetadataMaps; + + const relationConnectQueryConfigs = computeRelationConnectQueryConfigs( + entities, + objectMetadata, + objectMetadataMap, + relationConnectQueryFieldsByEntityIndex, + ); + + return relationConnectQueryConfigs; + } + + async processRelationNestedQueries({ + entities, + relationNestedConfig, + queryBuilder, + }: { + entities: + | QueryDeepPartialEntityWithNestedRelationFields[] + | QueryDeepPartialEntityWithNestedRelationFields; + relationNestedConfig: [ + RelationConnectQueryConfig[], + RelationDisconnectQueryFieldsByEntityIndex, + ]; + queryBuilder: WorkspaceSelectQueryBuilder; + }): Promise[]> { + const entitiesArray = Array.isArray(entities) ? entities : [entities]; + + const [ + relationConnectQueryConfigs, + relationDisconnectQueryFieldsByEntityIndex, + ] = relationNestedConfig; + + const updatedEntitiesWithDisconnect = this.processRelationDisconnect({ + entities: entitiesArray, + relationDisconnectQueryFieldsByEntityIndex, + }); + + const updatedEntitiesWithConnect = await this.processRelationConnect({ + entities: updatedEntitiesWithDisconnect, + relationConnectQueryConfigs, + queryBuilder, + }); + + return updatedEntitiesWithConnect; + } + + private async processRelationConnect({ + entities, + relationConnectQueryConfigs, + queryBuilder, + }: { + entities: QueryDeepPartialEntityWithNestedRelationFields[]; + relationConnectQueryConfigs: RelationConnectQueryConfig[]; + queryBuilder: WorkspaceSelectQueryBuilder; + }): Promise[]> { + if (relationConnectQueryConfigs.length === 0) return entities; + + const recordsToConnectWithConfig = await this.executeConnectQueries( + relationConnectQueryConfigs, + queryBuilder, + ); + + const updatedEntities = this.updateEntitiesWithRecordToConnectId( + entities, + recordsToConnectWithConfig, + ); + + return updatedEntities; + } + + private async executeConnectQueries( + relationConnectQueryConfigs: RelationConnectQueryConfig[], + queryBuilder: WorkspaceSelectQueryBuilder, + ): Promise<[RelationConnectQueryConfig, Record[]][]> { + const allRecordsToConnectWithConfig: [ + RelationConnectQueryConfig, + Record[], + ][] = []; + + for (const connectQueryConfig of relationConnectQueryConfigs) { + const { clause, parameters } = createSqlWhereTupleInClause( + connectQueryConfig.recordToConnectConditions, + connectQueryConfig.targetObjectName, + ); + + queryBuilder.expressionMap.aliases = []; + queryBuilder.expressionMap.mainAlias = undefined; + + const recordsToConnect = await queryBuilder + .select(getRecordToConnectFields(connectQueryConfig)) + .where(clause, parameters) + .from( + connectQueryConfig.targetObjectName, + connectQueryConfig.targetObjectName, + ) + .getRawMany(); + + allRecordsToConnectWithConfig.push([ + connectQueryConfig, + recordsToConnect, + ]); + } + + return allRecordsToConnectWithConfig; + } + + private updateEntitiesWithRecordToConnectId( + entities: QueryDeepPartialEntityWithNestedRelationFields[], + 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; + }); + } + + private processRelationDisconnect({ + entities, + relationDisconnectQueryFieldsByEntityIndex, + }: { + entities: QueryDeepPartialEntityWithNestedRelationFields[]; + relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex; + }): QueryDeepPartialEntityWithNestedRelationFields[] { + return entities.map((entity, index) => { + const nestedRelationDisconnectFields = + relationDisconnectQueryFieldsByEntityIndex[index]; + + if (!isDefined(nestedRelationDisconnectFields)) return entity; + + for (const [disconnectFieldName, disconnectObject] of Object.entries( + nestedRelationDisconnectFields ?? {}, + )) { + entity = { + ...entity, + [disconnectFieldName]: null, + ...(disconnectObject[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT] === + true + ? { [getAssociatedRelationFieldName(disconnectFieldName)]: null } + : {}), + }; + } + + return entity; + }); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts index c6ee31bf2..7f8240206 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts @@ -5,7 +5,6 @@ import { InsertResult, ObjectLiteral, } from 'typeorm'; -import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; @@ -13,10 +12,14 @@ import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/works import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { QueryDeepPartialEntityWithNestedRelationFields } 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 { RelationDisconnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type'; import { TwentyORMException, TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries'; import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils'; import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; @@ -34,6 +37,11 @@ export class WorkspaceInsertQueryBuilder< private internalContext: WorkspaceInternalContext; private authContext?: AuthContext; private featureFlagMap?: FeatureFlagMap; + private relationNestedQueries: RelationNestedQueries; + private relationNestedConfig: [ + RelationConnectQueryConfig[], + RelationDisconnectQueryFieldsByEntityIndex, + ]; constructor( queryBuilder: InsertQueryBuilder, @@ -49,6 +57,9 @@ export class WorkspaceInsertQueryBuilder< this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; this.authContext = authContext; this.featureFlagMap = featureFlagMap; + this.relationNestedQueries = new RelationNestedQueries( + this.internalContext, + ); } override clone(): this { @@ -65,10 +76,18 @@ export class WorkspaceInsertQueryBuilder< } override values( - values: QueryDeepPartialEntity | QueryDeepPartialEntity[], + values: + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[], ): this { const mainAliasTarget = this.getMainAliasTarget(); + this.relationNestedConfig = + this.relationNestedQueries.prepareNestedRelationQueries( + values, + mainAliasTarget, + ); + const objectMetadata = getObjectMetadataFromEntityTarget( mainAliasTarget, this.internalContext, @@ -96,6 +115,25 @@ export class WorkspaceInsertQueryBuilder< this.internalContext, ); + const nestedRelationQueryBuilder = new WorkspaceSelectQueryBuilder( + this as unknown as WorkspaceSelectQueryBuilder, + this.objectRecordsPermissions, + this.internalContext, + this.shouldBypassPermissionChecks, + this.authContext, + ); + + const updatedValues = + await this.relationNestedQueries.processRelationNestedQueries({ + entities: this.expressionMap.valuesSet as + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[], + relationNestedConfig: this.relationNestedConfig, + queryBuilder: nestedRelationQueryBuilder, + }); + + this.expressionMap.valuesSet = updatedValues; + const result = await super.execute(); const formattedResult = formatResult( diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts index ff1ec3443..757da9ebe 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts @@ -13,10 +13,14 @@ import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/works import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { QueryDeepPartialEntityWithNestedRelationFields } 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 { RelationDisconnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type'; import { TwentyORMException, TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries'; import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils'; import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; @@ -33,6 +37,12 @@ export class WorkspaceUpdateQueryBuilder< private internalContext: WorkspaceInternalContext; private authContext?: AuthContext; private featureFlagMap?: FeatureFlagMap; + private relationNestedQueries: RelationNestedQueries; + private relationNestedConfig: [ + RelationConnectQueryConfig[], + RelationDisconnectQueryFieldsByEntityIndex, + ]; + constructor( queryBuilder: UpdateQueryBuilder, objectRecordsPermissions: ObjectRecordsPermissions, @@ -47,13 +57,16 @@ export class WorkspaceUpdateQueryBuilder< this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; this.authContext = authContext; this.featureFlagMap = featureFlagMap; + this.relationNestedQueries = new RelationNestedQueries( + this.internalContext, + ); } override clone(): this { const clonedQueryBuilder = super.clone(); return new WorkspaceUpdateQueryBuilder( - clonedQueryBuilder, + clonedQueryBuilder as UpdateQueryBuilder, this.objectRecordsPermissions, this.internalContext, this.shouldBypassPermissionChecks, @@ -94,6 +107,26 @@ export class WorkspaceUpdateQueryBuilder< const before = await eventSelectQueryBuilder.getMany(); + const nestedRelationQueryBuilder = new WorkspaceSelectQueryBuilder( + this as unknown as WorkspaceSelectQueryBuilder, + this.objectRecordsPermissions, + this.internalContext, + this.shouldBypassPermissionChecks, + this.authContext, + ); + + const updatedValues = + await this.relationNestedQueries.processRelationNestedQueries({ + entities: this.expressionMap.valuesSet as + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[], + relationNestedConfig: this.relationNestedConfig, + queryBuilder: nestedRelationQueryBuilder, + }); + + this.expressionMap.valuesSet = + updatedValues.length === 1 ? updatedValues[0] : updatedValues; + const formattedBefore = formatResult( before, objectMetadata, @@ -131,7 +164,21 @@ export class WorkspaceUpdateQueryBuilder< }; } - override set(_values: QueryDeepPartialEntity): this { + override set( + _values: + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[], + ): this; + + override set(_values: QueryDeepPartialEntity): this; + + override set( + _values: + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[] + | QueryDeepPartialEntity + | QueryDeepPartialEntity[], + ): this { const mainAliasTarget = this.getMainAliasTarget(); const objectMetadata = getObjectMetadataFromEntityTarget( @@ -139,9 +186,19 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, ); + const extendedValues = _values as + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[]; + + this.relationNestedConfig = + this.relationNestedQueries.prepareNestedRelationQueries( + extendedValues, + mainAliasTarget, + ); + const formattedUpdateSet = formatData(_values, objectMetadata); - return super.set(formattedUpdateSet); + return super.set(formattedUpdateSet as QueryDeepPartialEntity); } override select(): WorkspaceSelectQueryBuilder { 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 75c7fc433..081289305 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 @@ -27,7 +27,7 @@ import { PermissionsException, PermissionsExceptionCode, } from 'src/engine/metadata-modules/permissions/permissions.exception'; -import { QueryDeepPartialEntityWithRelationConnect } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type'; +import { QueryDeepPartialEntityWithNestedRelationFields } 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 { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; @@ -536,8 +536,8 @@ export class WorkspaceRepository< */ override async insert( entity: - | QueryDeepPartialEntityWithRelationConnect - | QueryDeepPartialEntityWithRelationConnect[], + | QueryDeepPartialEntityWithNestedRelationFields + | QueryDeepPartialEntityWithNestedRelationFields[], entityManager?: WorkspaceEntityManager, selectedColumns?: string[], ): Promise { 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 index 8c55f79a6..858fb5ccd 100644 --- 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 @@ -160,9 +160,10 @@ describe('computeRelationConnectQueryConfigs', () => { peopleEntityInputs, personMetadata, objectMetadataMaps, + {}, ); - expect(result).toEqual({}); + expect(result).toEqual([]); }); it('should throw an error if a connect field is not a relation field', () => { @@ -176,11 +177,18 @@ describe('computeRelationConnectQueryConfigs', () => { }, ]; + const relationConnectQueryFieldsByEntityIndex = { + '0': { + name: { connect: { where: { name: { lastName: 'Doe' } } } }, + }, + }; + expect(() => { computeRelationConnectQueryConfigs( peopleEntityInputs, personMetadata, objectMetadataMaps, + relationConnectQueryFieldsByEntityIndex, ); }).toThrow('Connect is not allowed for name on person'); }); @@ -195,11 +203,18 @@ describe('computeRelationConnectQueryConfigs', () => { }, ]; + const relationConnectQueryFieldsByEntityIndex = { + '0': { + 'company-related-to-1': { connect: { where: { name: 'company1' } } }, + }, + }; + expect(() => { computeRelationConnectQueryConfigs( peopleEntityInputs, personMetadata, objectMetadataMaps, + relationConnectQueryFieldsByEntityIndex, ); }).toThrow( "Missing required fields: at least one unique constraint have to be fully populated for 'company-related-to-1'.", @@ -222,11 +237,26 @@ describe('computeRelationConnectQueryConfigs', () => { }, ]; + const relationConnectQueryFieldsByEntityIndex = { + '0': { + 'company-related-to-1': { + connect: { + where: { + domainName: { primaryLinkUrl: 'company1.com' }, + id: '1', + address: 'company1 address', + }, + }, + }, + }, + }; + expect(() => { computeRelationConnectQueryConfigs( peopleEntityInputs, personMetadata, objectMetadataMaps, + relationConnectQueryFieldsByEntityIndex, ); }).toThrow( "Field address is not a unique constraint field for 'company-related-to-1'.", @@ -255,11 +285,27 @@ describe('computeRelationConnectQueryConfigs', () => { }, ]; + const relationConnectQueryFieldsByEntityIndex = { + '0': { + 'company-related-to-1': { + connect: { + where: { + domainName: { primaryLinkUrl: 'company1.com' }, + }, + }, + }, + }, + '1': { + 'company-related-to-1': { connect: { where: { id: '2' } } }, + }, + }; + expect(() => { computeRelationConnectQueryConfigs( peopleEntityInputs, personMetadata, objectMetadataMaps, + relationConnectQueryFieldsByEntityIndex, ); }).toThrow( 'Expected the same constraint fields to be used consistently across all operations for company-related-to-1.', @@ -298,14 +344,42 @@ describe('computeRelationConnectQueryConfigs', () => { }, ]; + const relationConnectQueryFieldsByEntityIndex = { + '0': { + 'company-related-to-1': { + connect: { + where: { domainName: { primaryLinkUrl: 'company.com' } }, + }, + }, + 'company-related-to-2': { + connect: { + where: { id: '1' }, + }, + }, + }, + '1': { + '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, + relationConnectQueryFieldsByEntityIndex, ); - expect(result).toEqual({ - 'company-related-to-1': { + expect(result).toEqual([ + { connectFieldName: 'company-related-to-1', recordToConnectConditions: [ [['domainNamePrimaryLinkUrl', 'company.com']], @@ -326,7 +400,7 @@ describe('computeRelationConnectQueryConfigs', () => { }, ], }, - 'company-related-to-2': { + { connectFieldName: 'company-related-to-2', recordToConnectConditions: [[['id', '1']], [['id', '2']]], recordToConnectConditionByEntityIndex: { @@ -344,6 +418,6 @@ describe('computeRelationConnectQueryConfigs', () => { }, ], }, - }); + ]); }); }); 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 index fdf503736..261ae4c7e 100644 --- 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 @@ -17,7 +17,7 @@ describe('createSqlWhereTupleInClause', () => { const result = createSqlWhereTupleInClause(conditions, tableName); expect(result.clause).toBe( - '(table_name.field1, table_name.field2) IN ((:value0_0, :value0_1), (:value1_0, :value1_1))', + '("table_name"."field1", "table_name"."field2") IN ((:value0_0, :value0_1), (:value1_0, :value1_1))', ); expect(result.parameters).toEqual({ value0_0: 'value1', 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 index 8544dbe46..9e3611558 100644 --- 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 @@ -16,6 +16,7 @@ import { RelationConnectQueryConfig, UniqueConstraintCondition, } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type'; +import { RelationConnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type'; import { TwentyORMException, TwentyORMExceptionCode, @@ -28,19 +29,19 @@ export const computeRelationConnectQueryConfigs = ( entities: Record[], objectMetadata: ObjectMetadataItemWithFieldMaps, objectMetadataMap: ObjectMetadataMaps, + relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex, ) => { const allConnectQueryConfigs: Record = {}; for (const [entityIndex, entity] of entities.entries()) { - const connectFields = extractConnectFields(entity); + const nestedRelationConnectFields = + relationConnectQueryFieldsByEntityIndex[entityIndex]; - if (connectFields.length === 0) { - continue; - } - - for (const connectField of connectFields) { - const [connectFieldName, connectObject] = Object.entries(connectField)[0]; + if (!isDefined(nestedRelationConnectFields)) continue; + for (const [connectFieldName, connectObject] of Object.entries( + nestedRelationConnectFields, + )) { const { recordToConnectCondition, uniqueConstraintFields, @@ -78,7 +79,7 @@ export const computeRelationConnectQueryConfigs = ( } } - return allConnectQueryConfigs; + return Object.values(allConnectQueryConfigs); }; const updateConnectQueryConfigs = ( @@ -177,63 +178,6 @@ const computeRecordToConnectCondition = ( }; }; -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, 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 index b8b4523ad..e0c434507 100644 --- 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 @@ -5,7 +5,7 @@ export const createSqlWhereTupleInClause = ( const fieldNames = conditions[0].map(([field, _]) => field); const tupleClause = fieldNames - .map((field) => `${tableName}.${field}`) + .map((field) => `"${tableName}"."${field}"`) .join(', '); const valuePlaceholders = conditions .map((_, index) => { diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/extract-nested-relation-fields-by-entity-index.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/extract-nested-relation-fields-by-entity-index.util.ts new file mode 100644 index 000000000..6fdf38550 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/extract-nested-relation-fields-by-entity-index.util.ts @@ -0,0 +1,135 @@ +import { isDefined } from 'class-validator'; +import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants'; + +import { + ConnectObject, + DisconnectObject, +} from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type'; +import { + RelationConnectQueryFieldsByEntityIndex, + RelationDisconnectQueryFieldsByEntityIndex, +} from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; + +const hasRelationConnect = (value: unknown): value is ConnectObject => { + if (!isDefined(value) || typeof value !== 'object') { + return false; + } + + const obj = value as Record; + + if ( + !isDefined(obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT]) || + typeof obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT] !== 'object' + ) { + return false; + } + + const connect = obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT] as Record< + string, + unknown + >; + + if ( + !isDefined(connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE]) || + typeof connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE] !== 'object' + ) { + return false; + } + + const where = connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE] as Record< + string, + unknown + >; + + 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 hasRelationDisconnect = (value: unknown): value is DisconnectObject => { + if (!isDefined(value) || typeof value !== 'object') return false; + + const obj = value as Record; + + if ( + !isDefined(obj[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT]) || + typeof obj[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT] !== 'boolean' + ) + return false; + + return true; +}; + +export const extractNestedRelationFieldsByEntityIndex = ( + entities: Record[], +): { + relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex; + relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex; +} => { + const relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex = + {}; + const relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex = + {}; + + for (const [entityIndex, entity] of Object.entries(entities)) { + for (const [key, value] of Object.entries(entity)) { + const hasConnect = hasRelationConnect(value); + const hasDisconnect = hasRelationDisconnect(value); + + if (hasConnect && hasDisconnect) { + throw new TwentyORMException( + `Cannot have both connect and disconnect for the same field on ${entity.key}.`, + TwentyORMExceptionCode.CONNECT_NOT_ALLOWED, + ); + } + + const relationConnectQueryFields = + relationConnectQueryFieldsByEntityIndex?.[entityIndex] || {}; + + if (hasConnect) { + relationConnectQueryFieldsByEntityIndex[entityIndex] = { + ...relationConnectQueryFields, + [key]: value, + }; + } + + const relationDisconnectQueryFields = + relationDisconnectQueryFieldsByEntityIndex?.[entityIndex] || {}; + + if (hasDisconnect) { + relationDisconnectQueryFieldsByEntityIndex[entityIndex] = { + ...relationDisconnectQueryFields, + [key]: value, + }; + } + } + } + + return { + relationConnectQueryFieldsByEntityIndex, + relationDisconnectQueryFieldsByEntityIndex, + }; +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/nested-relation-queries.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/nested-relation-queries.integration-spec.ts new file mode 100644 index 000000000..141f59e4c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/nested-relation-queries.integration-spec.ts @@ -0,0 +1,552 @@ +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 { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils'; +import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util'; +import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util'; + +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +const PERSON_GQL_FIELDS_WITH_COMPANY = ` + id + city + company { + id + } +`; + +describe('relation connect in workspace createOne/createMany resolvers (e2e)', () => { + const [company1, company2] = [ + { id: TEST_COMPANY_1_ID, domainName: { primaryLinkUrl: 'company1.com' } }, + { id: TEST_COMPANY_2_ID, domainName: { primaryLinkUrl: 'company2.com' } }, + ]; + + beforeAll(async () => { + await makeGraphqlAPIRequest( + destroyManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: `id`, + filter: { + id: { + in: [TEST_COMPANY_1_ID, TEST_COMPANY_2_ID], + }, + }, + }), + ); + + await performCreateManyOperation('company', 'companies', `id`, [ + company1, + company2, + ]); + }); + + beforeEach(async () => { + await makeGraphqlAPIRequest( + destroyManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: `id`, + filter: { + id: { + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], + }, + }, + }), + ); + }); + + afterAll(async () => { + await makeGraphqlAPIRequest( + destroyManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: `id`, + filter: { + id: { + in: [TEST_COMPANY_1_ID, TEST_COMPANY_2_ID], + }, + }, + }), + ); + await makeGraphqlAPIRequest( + destroyManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: `id`, + filter: { + id: { + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], + }, + }, + }), + ); + }); + + 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 - upsert false', 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 connect to other records through a MANY-TO-ONE relation - create Many - upsert true', async () => { + const createPersonToUpdateOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + city: 'existing-record', + companyId: TEST_COMPANY_1_ID, + }, + }); + + await makeGraphqlAPIRequest(createPersonToUpdateOperation); + + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: [ + { + id: TEST_PERSON_1_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company2.com' } }, + }, + }, + }, + { + id: TEST_PERSON_2_ID, + city: 'new-record', + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company1.com' } }, + }, + }, + }, + ], + upsert: true, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.createPeople).toBeDefined(); + expect(response.body.data.createPeople).toHaveLength(2); + + const updatedPerson = response.body.data.createPeople.find( + (person: ObjectRecord) => person.id === TEST_PERSON_1_ID, + ); + + const insertedPerson = response.body.data.createPeople.find( + (person: ObjectRecord) => person.id === TEST_PERSON_2_ID, + ); + + expect(updatedPerson.company.id).toBe(TEST_COMPANY_2_ID); + expect(updatedPerson.city).toBe('existing-record'); + + expect(insertedPerson.company.id).toBe(TEST_COMPANY_1_ID); + expect(insertedPerson.city).toBe('new-record'); + }); + + it('should connect to other records through a MANY-TO-ONE relation - update One', async () => { + const createPersonToUpdateOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + city: 'existing-record', + companyId: TEST_COMPANY_1_ID, + }, + }); + + await makeGraphqlAPIRequest(createPersonToUpdateOperation); + + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + recordId: TEST_PERSON_1_ID, + data: { + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company2.com' } }, + }, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.updatePerson).toBeDefined(); + expect(response.body.data.updatePerson.company.id).toBe(TEST_COMPANY_2_ID); + expect(response.body.data.updatePerson.city).toBe('existing-record'); + }); + + it('should connect to other records through a MANY-TO-ONE relation - update Many', async () => { + const createPeopleToUpdateOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: [ + { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + }, + { + id: TEST_PERSON_2_ID, + }, + ], + }); + + await makeGraphqlAPIRequest(createPeopleToUpdateOperation); + + const graphqlOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + filter: { + id: { + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], + }, + }, + data: { + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company2.com' } }, + }, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.updatePeople).toBeDefined(); + expect(response.body.data.updatePeople).toHaveLength(2); + + expect(response.body.data.updatePeople[0].company.id).toBe( + TEST_COMPANY_2_ID, + ); + expect(response.body.data.updatePeople[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, + ); + }); + + it('should throw an error if connect and disconnect are both provided', 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' } }, + }, + disconnect: true, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + 'Cannot have both connect and disconnect for the same field on undefined.', + ); + }); + + it('should disconnect a record from a MANY-TO-ONE relation - update One', async () => { + const createPersonToUpdateOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + }, + }); + + await makeGraphqlAPIRequest(createPersonToUpdateOperation); + + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + recordId: TEST_PERSON_1_ID, + data: { + company: { + disconnect: true, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.updatePerson).toBeDefined(); + expect(response.body.data.updatePerson.company?.id).toBeUndefined(); + }); + it('should disconnect a record from a MANY-TO-ONE relation - update Many', async () => { + const createPeopleToUpdateOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: [ + { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + }, + { + id: TEST_PERSON_2_ID, + companyId: TEST_COMPANY_2_ID, + }, + ], + }); + + await makeGraphqlAPIRequest(createPeopleToUpdateOperation); + + const graphqlOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + filter: { + id: { + in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID], + }, + }, + data: { + company: { + disconnect: true, + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.updatePeople).toBeDefined(); + expect(response.body.data.updatePeople).toHaveLength(2); + + expect(response.body.data.updatePeople[0].company?.id).toBeUndefined(); + expect(response.body.data.updatePeople[1].company?.id).toBeUndefined(); + }); + it('should disconnect a record from a MANY-TO-ONE relation - create Many - upsert true', async () => { + const createPersonToUpdateOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: { + id: TEST_PERSON_1_ID, + companyId: TEST_COMPANY_1_ID, + }, + }); + + await makeGraphqlAPIRequest(createPersonToUpdateOperation); + + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY, + data: [ + { + id: TEST_PERSON_1_ID, + company: { + disconnect: true, + }, + }, + { + id: TEST_PERSON_2_ID, + company: { + connect: { + where: { domainName: { primaryLinkUrl: 'company2.com' } }, + }, + }, + }, + ], + upsert: true, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data.createPeople).toBeDefined(); + expect(response.body.data.createPeople).toHaveLength(2); + + const updatedPerson = response.body.data.createPeople.find( + (person: ObjectRecord) => person.id === TEST_PERSON_1_ID, + ); + + const insertedPerson = response.body.data.createPeople.find( + (person: ObjectRecord) => person.id === TEST_PERSON_2_ID, + ); + + expect(updatedPerson.company?.id).toBeUndefined(); + expect(insertedPerson.company?.id).toBe(TEST_COMPANY_2_ID); + }); +}); 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 deleted file mode 100644 index 7aee5a75e..000000000 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/relation-connect.integration-spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -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, - ); - }); -}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-many-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-many-operation-factory.util.ts index 2cde5237c..7bd902182 100644 --- a/packages/twenty-server/test/integration/graphql/utils/create-many-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/create-many-operation-factory.util.ts @@ -6,6 +6,7 @@ type CreateManyOperationFactoryParams = { objectMetadataPluralName: string; gqlFields: string; data?: object; + upsert?: boolean; }; export const createManyOperationFactory = ({ @@ -13,15 +14,17 @@ export const createManyOperationFactory = ({ objectMetadataPluralName, gqlFields, data = {}, + upsert = false, }: CreateManyOperationFactoryParams) => ({ query: gql` - mutation Create${capitalize(objectMetadataSingularName)}($data: [${capitalize(objectMetadataSingularName)}CreateInput!]!) { - create${capitalize(objectMetadataPluralName)}(data: $data) { + mutation Create${capitalize(objectMetadataSingularName)}($data: [${capitalize(objectMetadataSingularName)}CreateInput!]!, $upsert: Boolean) { + create${capitalize(objectMetadataPluralName)}(data: $data, upsert: $upsert) { ${gqlFields} } } `, variables: { data, + upsert, }, }); diff --git a/packages/twenty-shared/src/constants/RelationNestedQueriesKeyword.ts b/packages/twenty-shared/src/constants/RelationNestedQueriesKeyword.ts new file mode 100644 index 000000000..cc8097cb8 --- /dev/null +++ b/packages/twenty-shared/src/constants/RelationNestedQueriesKeyword.ts @@ -0,0 +1,5 @@ +export const RELATION_NESTED_QUERY_KEYWORDS = { + CONNECT: 'connect', + CONNECT_WHERE: 'where', + DISCONNECT: 'disconnect', +} as const; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 293129327..2060f3a41 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -13,6 +13,7 @@ export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestric export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes'; export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords'; export { QUERY_MAX_RECORDS } from './QueryMaxRecords'; +export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword'; export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions'; export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl'; export { TWENTY_ICONS_BASE_URL } from './TwentyIconsBaseUrl';