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
This commit is contained in:
Etienne
2025-07-24 17:04:38 +02:00
committed by GitHub
parent 7bfa003682
commit 88a6913217
20 changed files with 1182 additions and 445 deletions

View File

@ -275,6 +275,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
const existingRec = existingRecords.find(
(existingRecord) =>
isDefined(existingRecord[field.column]) &&
existingRecord[field.column] === requestFieldValue,
);

View File

@ -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,

View File

@ -200,7 +200,7 @@ const generateRelationField = <
};
if (
[InputTypeDefinitionKind.Create].includes(
[InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes(
kind as InputTypeDefinitionKind,
) &&
isDefined(fieldMetadata.relationTargetObjectMetadataId)

View File

@ -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<string, string>;
export type ConnectWhere = Record<string, ConnectWhereValue>;
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<T> = {
[K in keyof T]: T[K] extends BaseWorkspaceEntity | null ? K : never;
}[keyof T];
export type QueryDeepPartialEntityWithRelationConnect<T> = Omit<
export type QueryDeepPartialEntityWithNestedRelationFields<T> = Omit<
QueryDeepPartialEntity<T>,
EntityRelationFields<T>
> & {
[K in keyof T]?: T[K] extends BaseWorkspaceEntity | null
? T[K] | ConnectObject
? T[K] | ConnectObject | DisconnectObject
: T[K];
};

View File

@ -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;
};
};

View File

@ -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<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
entity:
| QueryDeepPartialEntityWithRelationConnect<Entity>
| QueryDeepPartialEntityWithRelationConnect<Entity>[],
| QueryDeepPartialEntityWithNestedRelationFields<Entity>
| QueryDeepPartialEntityWithNestedRelationFields<Entity>[],
selectedColumns: string[] = [],
permissionOptions?: PermissionOptions,
): Promise<InsertResult> {
const entityArray = Array.isArray(entity) ? entity : [entity];
const connectedEntities = await this.processRelationConnect<Entity>(
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<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithRelationConnect<Entity>[],
target: EntityTarget<Entity>,
permissionOptions?: PermissionOptions,
): Promise<QueryDeepPartialEntity<Entity>[]> {
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<Entity>(
entities,
recordsToConnectWithConfig,
);
return updatedEntities;
}
private async executeConnectQueries(
relationConnectQueryConfigs: Record<string, RelationConnectQueryConfig>,
permissionOptions?: PermissionOptions,
): Promise<[RelationConnectQueryConfig, Record<string, unknown>[]][]> {
const AllRecordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][] = [];
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<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithRelationConnect<Entity>[],
recordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][],
): QueryDeepPartialEntity<Entity>[] {
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;
});
}
}

View File

@ -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<Entity extends ObjectLiteral>(
entities:
| QueryDeepPartialEntityWithNestedRelationFields<Entity>[]
| QueryDeepPartialEntityWithNestedRelationFields<Entity>,
target: EntityTarget<Entity>,
): [
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<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[],
target: EntityTarget<Entity>,
relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex,
) {
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const objectMetadataMap = this.internalContext.objectMetadataMaps;
const relationConnectQueryConfigs = computeRelationConnectQueryConfigs(
entities,
objectMetadata,
objectMetadataMap,
relationConnectQueryFieldsByEntityIndex,
);
return relationConnectQueryConfigs;
}
async processRelationNestedQueries<Entity extends ObjectLiteral>({
entities,
relationNestedConfig,
queryBuilder,
}: {
entities:
| QueryDeepPartialEntityWithNestedRelationFields<Entity>[]
| QueryDeepPartialEntityWithNestedRelationFields<Entity>;
relationNestedConfig: [
RelationConnectQueryConfig[],
RelationDisconnectQueryFieldsByEntityIndex,
];
queryBuilder: WorkspaceSelectQueryBuilder<Entity>;
}): Promise<QueryDeepPartialEntity<Entity>[]> {
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<Entity extends ObjectLiteral>({
entities,
relationConnectQueryConfigs,
queryBuilder,
}: {
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[];
relationConnectQueryConfigs: RelationConnectQueryConfig[];
queryBuilder: WorkspaceSelectQueryBuilder<Entity>;
}): Promise<QueryDeepPartialEntity<Entity>[]> {
if (relationConnectQueryConfigs.length === 0) return entities;
const recordsToConnectWithConfig = await this.executeConnectQueries(
relationConnectQueryConfigs,
queryBuilder,
);
const updatedEntities = this.updateEntitiesWithRecordToConnectId<Entity>(
entities,
recordsToConnectWithConfig,
);
return updatedEntities;
}
private async executeConnectQueries<Entity extends ObjectLiteral>(
relationConnectQueryConfigs: RelationConnectQueryConfig[],
queryBuilder: WorkspaceSelectQueryBuilder<Entity>,
): Promise<[RelationConnectQueryConfig, Record<string, unknown>[]][]> {
const allRecordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][] = [];
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<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[],
recordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][],
): QueryDeepPartialEntity<Entity>[] {
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<Entity extends ObjectLiteral>({
entities,
relationDisconnectQueryFieldsByEntityIndex,
}: {
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[];
relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex;
}): QueryDeepPartialEntityWithNestedRelationFields<Entity>[] {
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;
});
}
}

View File

@ -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<T>,
@ -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<T> | QueryDeepPartialEntity<T>[],
values:
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
): 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<T>,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
);
const updatedValues =
await this.relationNestedQueries.processRelationNestedQueries({
entities: this.expressionMap.valuesSet as
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
relationNestedConfig: this.relationNestedConfig,
queryBuilder: nestedRelationQueryBuilder,
});
this.expressionMap.valuesSet = updatedValues;
const result = await super.execute();
const formattedResult = formatResult<T[]>(

View File

@ -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<T>,
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<T>,
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<T>,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
);
const updatedValues =
await this.relationNestedQueries.processRelationNestedQueries({
entities: this.expressionMap.valuesSet as
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
relationNestedConfig: this.relationNestedConfig,
queryBuilder: nestedRelationQueryBuilder,
});
this.expressionMap.valuesSet =
updatedValues.length === 1 ? updatedValues[0] : updatedValues;
const formattedBefore = formatResult<T[]>(
before,
objectMetadata,
@ -131,7 +164,21 @@ export class WorkspaceUpdateQueryBuilder<
};
}
override set(_values: QueryDeepPartialEntity<T>): this {
override set(
_values:
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
): this;
override set(_values: QueryDeepPartialEntity<T>): this;
override set(
_values:
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[]
| QueryDeepPartialEntity<T>
| QueryDeepPartialEntity<T>[],
): this {
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
@ -139,9 +186,19 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext,
);
const extendedValues = _values as
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[];
this.relationNestedConfig =
this.relationNestedQueries.prepareNestedRelationQueries(
extendedValues,
mainAliasTarget,
);
const formattedUpdateSet = formatData(_values, objectMetadata);
return super.set(formattedUpdateSet);
return super.set(formattedUpdateSet as QueryDeepPartialEntity<T>);
}
override select(): WorkspaceSelectQueryBuilder<T> {

View File

@ -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<T>
| QueryDeepPartialEntityWithRelationConnect<T>[],
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
entityManager?: WorkspaceEntityManager,
selectedColumns?: string[],
): Promise<InsertResult> {

View File

@ -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', () => {
},
],
},
});
]);
});
});

View File

@ -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',

View File

@ -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<string, unknown>[],
objectMetadata: ObjectMetadataItemWithFieldMaps,
objectMetadataMap: ObjectMetadataMaps,
relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex,
) => {
const allConnectQueryConfigs: Record<string, RelationConnectQueryConfig> = {};
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<string, unknown>,
): { [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<string, unknown>;
if (!isDefined(obj.connect) || typeof obj.connect !== 'object') {
return false;
}
const connect = obj.connect as Record<string, unknown>;
if (!isDefined(connect.where) || typeof connect.where !== 'object') {
return false;
}
const where = 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<string, unknown>;
return Object.values(subObj).every(
(subValue) => typeof subValue === 'string',
);
}
return false;
});
};
const checkUniqueConstraintFullyPopulated = (
objectMetadata: ObjectMetadataItemWithFieldMaps,
connectObject: ConnectObject,

View File

@ -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) => {

View File

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>[],
): {
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,
};
};

View File

@ -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);
});
});

View File

@ -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,
);
});
});

View File

@ -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,
},
});

View File

@ -0,0 +1,5 @@
export const RELATION_NESTED_QUERY_KEYWORDS = {
CONNECT: 'connect',
CONNECT_WHERE: 'where',
DISCONNECT: 'disconnect',
} as const;

View File

@ -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';