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:
@ -275,6 +275,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
|
||||
|
||||
const existingRec = existingRecords.find(
|
||||
(existingRecord) =>
|
||||
isDefined(existingRecord[field.column]) &&
|
||||
existingRecord[field.column] === requestFieldValue,
|
||||
);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -200,7 +200,7 @@ const generateRelationField = <
|
||||
};
|
||||
|
||||
if (
|
||||
[InputTypeDefinitionKind.Create].includes(
|
||||
[InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes(
|
||||
kind as InputTypeDefinitionKind,
|
||||
) &&
|
||||
isDefined(fieldMetadata.relationTargetObjectMetadataId)
|
||||
|
||||
@ -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];
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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[]>(
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
export const RELATION_NESTED_QUERY_KEYWORDS = {
|
||||
CONNECT: 'connect',
|
||||
CONNECT_WHERE: 'where',
|
||||
DISCONNECT: 'disconnect',
|
||||
} as const;
|
||||
@ -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';
|
||||
|
||||
Reference in New Issue
Block a user