Connect logic in Workspace Entity Manager (#13078)

Large PR, sorry for that. Don't hesitate to reach me to have full
context (env. 500lines for integration and unit tests)

- Add connect logic in Workspace Entity Manager
- Update QueryDeepPartialEntity type to enable dev to use connect
- Add integration test on createOne / createMany
- Add unit test to cover main utils
- Remove feature flag on connect

closes https://github.com/twentyhq/core-team-issues/issues/1148
closes https://github.com/twentyhq/core-team-issues/issues/1147
This commit is contained in:
Etienne
2025-07-09 14:16:28 +02:00
committed by GitHub
parent a95ca10f29
commit fce33004bc
27 changed files with 1293 additions and 80 deletions

View File

@ -683,7 +683,6 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'

View File

@ -647,7 +647,6 @@ export enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'

View File

@ -13,6 +13,8 @@ import { RecordTransformerException } from 'src/engine/core-modules/record-trans
import { recordTransformerGraphqlApiExceptionHandler } from 'src/engine/core-modules/record-transformer/utils/record-transformer-graphql-api-exception-handler.util';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util';
import { TwentyORMException } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { twentyORMGraphqlApiExceptionHandler } from 'src/engine/twenty-orm/utils/twenty-orm-graphql-api-exception-handler.util';
interface QueryFailedErrorWithCode extends QueryFailedError {
code: string;
@ -44,6 +46,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
return workspaceExceptionHandler(error);
case error instanceof GraphqlQueryRunnerException:
return graphqlQueryRunnerExceptionHandler(error);
case error instanceof TwentyORMException:
return twentyORMGraphqlApiExceptionHandler(error);
default:
throw error;
}

View File

@ -36,12 +36,10 @@ export class InputTypeDefinitionFactory {
objectMetadata,
kind,
options,
isRelationConnectEnabled = false,
}: {
objectMetadata: ObjectMetadataInterface;
kind: InputTypeDefinitionKind;
options: WorkspaceBuildSchemaOptions;
isRelationConnectEnabled?: boolean;
}): InputTypeDefinition {
// @ts-expect-error legacy noImplicitAny
const inputType = new GraphQLInputObjectType({
@ -89,7 +87,6 @@ export class InputTypeDefinitionFactory {
kind,
options,
typeFactory: this.inputTypeFactory,
isRelationConnectEnabled,
});
}
},

View File

@ -39,11 +39,6 @@ export class RelationConnectInputTypeDefinitionFactory {
kind: InputTypeDefinitionKind.Create,
type: fields,
},
{
target,
kind: InputTypeDefinitionKind.Update,
type: fields,
},
];
}

View File

@ -13,10 +13,4 @@ export interface WorkspaceBuildSchemaOptions {
* @default 'float'
*/
numberScalarMode?: NumberScalarMode;
/**
* Workspace ID - used to relation connect feature flag check
* TODO: remove once IS_RELATION_CONNECT_ENABLED is removed
*/
workspaceId?: string;
}

View File

@ -1,7 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
@ -11,7 +9,6 @@ import { CompositeObjectTypeDefinitionFactory } from 'src/engine/api/graphql/wor
import { EnumTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/enum-type-definition.factory';
import { ExtendObjectTypeDefinitionV2Factory } from 'src/engine/api/graphql/workspace-schema-builder/factories/extend-object-type-definition-v2.factory';
import { RelationConnectInputTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
@ -213,13 +210,6 @@ export class TypeDefinitionsGenerator {
objectMetadataCollection: ObjectMetadataInterface[],
options: WorkspaceBuildSchemaOptions,
) {
const isRelationConnectEnabled = isDefined(options.workspaceId)
? await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_RELATION_CONNECT_ENABLED,
options.workspaceId,
)
: false;
const inputTypeDefs = objectMetadataCollection
.map((objectMetadata) => {
const optionalExtendedObjectMetadata = {
@ -236,13 +226,11 @@ export class TypeDefinitionsGenerator {
objectMetadata,
kind: InputTypeDefinitionKind.Create,
options,
isRelationConnectEnabled,
}),
// Input type for update
this.inputTypeDefinitionFactory.create({
objectMetadata: optionalExtendedObjectMetadata,
kind: InputTypeDefinitionKind.Update,
isRelationConnectEnabled,
options,
}),
// Filter input type

View File

@ -47,13 +47,11 @@ export const generateFields = <
kind,
options,
typeFactory,
isRelationConnectEnabled = false,
}: {
objectMetadata: ObjectMetadataInterface;
kind: T;
options: WorkspaceBuildSchemaOptions;
typeFactory: TypeFactory<T>;
isRelationConnectEnabled?: boolean;
}): T extends InputTypeDefinitionKind
? GraphQLInputFieldConfigMap
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -80,7 +78,6 @@ export const generateFields = <
kind,
options,
typeFactory,
isRelationConnectEnabled,
});
} else {
generatedField = generateField({
@ -168,7 +165,6 @@ const generateRelationField = <
kind,
options,
typeFactory,
isRelationConnectEnabled,
}: {
fieldMetadata: FieldMetadataInterface<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
@ -176,7 +172,6 @@ const generateRelationField = <
kind: T;
options: WorkspaceBuildSchemaOptions;
typeFactory: TypeFactory<T>;
isRelationConnectEnabled: boolean;
}) => {
const relationField = {};
@ -208,11 +203,10 @@ const generateRelationField = <
};
if (
[InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes(
[InputTypeDefinitionKind.Create].includes(
kind as InputTypeDefinitionKind,
) &&
isDefined(fieldMetadata.relationTargetObjectMetadataId) &&
isRelationConnectEnabled
isDefined(fieldMetadata.relationTargetObjectMetadataId)
) {
type = typeFactory.create(
formatRelationConnectInputTarget(

View File

@ -81,9 +81,6 @@ export class WorkspaceSchemaFactory {
await this.workspaceGraphQLSchemaFactory.create(
objectMetadataCollection,
workspaceResolverBuilderMethodNames,
{
workspaceId: authContext.workspace.id,
},
);
usedScalarNames =

View File

@ -8,6 +8,5 @@ export enum FeatureFlagKey {
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
}

View File

@ -0,0 +1,26 @@
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
export type ConnectWhereValue = string | Record<string, string>;
export type ConnectWhere = Record<string, ConnectWhereValue>;
export type ConnectObject = {
connect: {
where: ConnectWhere;
};
};
type EntityRelationFields<T> = {
[K in keyof T]: T[K] extends BaseWorkspaceEntity | null ? K : never;
}[keyof T];
export type QueryDeepPartialEntityWithRelationConnect<T> = Omit<
QueryDeepPartialEntity<T>,
EntityRelationFields<T>
> & {
[K in keyof T]?: T[K] extends BaseWorkspaceEntity | null
? T[K] | ConnectObject
: T[K];
};

View File

@ -0,0 +1,18 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
export type UniqueFieldCondition = [field: string, value: string];
export type UniqueConstraintCondition = UniqueFieldCondition[];
export type RelationConnectQueryConfig = {
targetObjectName: string;
recordToConnectConditions: UniqueConstraintCondition[];
relationFieldName: string;
connectFieldName: string;
uniqueConstraintFields: FieldMetadataInterface<FieldMetadataType>[];
recordToConnectConditionByEntityIndex: {
[entityIndex: number]: UniqueConstraintCondition;
};
};

View File

@ -37,12 +37,22 @@ import {
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { QueryDeepPartialEntityWithRelationConnect } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import {
OperationType,
validateOperationIsPermittedOrThrow,
} from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util';
import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { getRecordToConnectFields } from 'src/engine/twenty-orm/utils/get-record-to-connect-fields.util';
type PermissionOptions = {
shouldBypassPermissionChecks?: boolean;
@ -165,11 +175,21 @@ export class WorkspaceEntityManager extends EntityManager {
);
}
override insert<Entity extends ObjectLiteral>(
override async insert<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
entity: QueryDeepPartialEntity<Entity> | QueryDeepPartialEntity<Entity>[],
entity:
| QueryDeepPartialEntityWithRelationConnect<Entity>
| QueryDeepPartialEntityWithRelationConnect<Entity>[],
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,
@ -178,7 +198,7 @@ export class WorkspaceEntityManager extends EntityManager {
)
.insert()
.into(target)
.values(entity)
.values(connectedEntities)
.execute();
}
@ -1321,4 +1341,118 @@ export class WorkspaceEntityManager extends EntityManager {
PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED,
);
}
private async processRelationConnect<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

@ -1,8 +1,12 @@
import { CustomException } from 'src/utils/custom-exception';
export class TwentyORMException extends CustomException {
constructor(message: string, code: TwentyORMExceptionCode) {
super(message, code);
constructor(
message: string,
code: TwentyORMExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
@ -14,4 +18,7 @@ export enum TwentyORMExceptionCode {
USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND = 'USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND',
MALFORMED_METADATA = 'MALFORMED_METADATA',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
CONNECT_RECORD_NOT_FOUND = 'CONNECT_RECORD_NOT_FOUND',
CONNECT_NOT_ALLOWED = 'CONNECT_NOT_ALLOWED',
CONNECT_UNIQUE_CONSTRAINT_ERROR = 'CONNECT_UNIQUE_CONSTRAINT_ERROR',
}

View File

@ -2,7 +2,6 @@ import { ObjectRecordsPermissions } from 'twenty-shared/types';
import {
DeepPartial,
DeleteResult,
EntitySchema,
EntityTarget,
FindManyOptions,
FindOneOptions,
@ -28,12 +27,12 @@ import {
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { QueryDeepPartialEntityWithRelationConnect } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
export class WorkspaceRepository<
T extends ObjectLiteral,
@ -552,7 +551,9 @@ export class WorkspaceRepository<
* INSERT METHODS
*/
override async insert(
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
entity:
| QueryDeepPartialEntityWithRelationConnect<T>
| QueryDeepPartialEntityWithRelationConnect<T>[],
entityManager?: WorkspaceEntityManager,
): Promise<InsertResult> {
const manager = entityManager || this.manager;
@ -913,36 +914,7 @@ export class WorkspaceRepository<
* PRIVATE METHODS
*/
private async getObjectMetadataFromTarget() {
const objectMetadataName =
typeof this.target === 'string'
? this.target
: WorkspaceEntitiesStorage.getObjectMetadataName(
this.internalContext.workspaceId,
this.target as EntitySchema,
);
if (!objectMetadataName) {
throw new Error('Object metadata name is missing');
}
const objectMetadata = getObjectMetadataMapItemByNameSingular(
this.internalContext.objectMetadataMaps,
objectMetadataName,
);
if (!objectMetadata) {
throw new Error(
`Object metadata for object "${objectMetadataName}" is missing ` +
`in workspace "${this.internalContext.workspaceId}" ` +
`with object metadata collection length: ${
Object.keys(
this.internalContext.objectMetadataMaps.idByNameSingular,
).length
}`,
);
}
return objectMetadata;
return getObjectMetadataFromEntityTarget(this.target, this.internalContext);
}
private async transformOptions<

View File

@ -0,0 +1,349 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util';
describe('computeRelationConnectQueryConfigs', () => {
const personMetadata = {
id: 'person-object-metadata-id',
nameSingular: 'person',
fieldsById: {
'person-id-field-id': {
id: 'person-id-field-id',
name: 'id',
type: FieldMetadataType.UUID,
label: 'id',
},
'person-name-field-id': {
id: 'person-name-field-id',
name: 'name',
type: FieldMetadataType.FULL_NAME,
label: 'name',
},
'person-company-1-field-id': {
id: 'person-company-1-field-id',
name: 'company-related-to-1',
type: FieldMetadataType.RELATION,
label: 'company-related-to-1',
relationTargetObjectMetadataId: 'company-object-metadata-id',
relationTargetFieldMetadataId: 'company-id-field-id',
settings: {
relationType: RelationType.MANY_TO_ONE,
},
},
'person-company-2-field-id': {
id: 'person-company-2-field-id',
name: 'company-related-to-2',
type: FieldMetadataType.RELATION,
label: 'company-related-to-2',
relationTargetObjectMetadataId: 'company-object-metadata-id',
relationTargetFieldMetadataId: 'company-id-field-id',
settings: {
relationType: RelationType.MANY_TO_ONE,
},
},
},
fieldIdByName: {
id: 'person-id-field-id',
name: 'person-name-field-id',
'company-related-to-1': 'person-company-1-field-id',
'company-related-to-2': 'person-company-2-field-id',
},
} as unknown as ObjectMetadataItemWithFieldMaps;
const companyMetadata = {
id: 'company-object-metadata-id',
nameSingular: 'company',
indexMetadatas: [
{
id: 'company-id-index-metadata-id',
name: 'company-id-index-metadata-name',
indexFieldMetadatas: [
{
fieldMetadataId: 'company-id-field-id',
},
],
isUnique: true,
},
{
id: 'company-domain-index-metadata-id',
name: 'company-domain-index-metadata-name',
indexFieldMetadatas: [
{
fieldMetadataId: 'company-domain-name-field-id',
},
],
isUnique: true,
},
{
id: 'company-composite-index-metadata-id',
name: 'company-composite-index-metadata-name',
indexFieldMetadatas: [
{
fieldMetadataId: 'company-name-field-id',
},
{
fieldMetadataId: 'company-description-field-id',
},
],
isUnique: true,
},
],
fieldsById: {
'company-id-field-id': {
id: 'company-id-field-id',
name: 'id',
type: FieldMetadataType.UUID,
label: 'id',
},
'company-name-field-id': {
id: 'company-name-field-id',
name: 'name',
type: FieldMetadataType.TEXT,
label: 'name',
},
'company-description-field-id': {
id: 'company-description-field-id',
name: 'description',
type: FieldMetadataType.TEXT,
label: 'description',
},
'company-domain-name-field-id': {
id: 'company-domain-name-field-id',
name: 'domainName',
type: FieldMetadataType.LINKS,
label: 'domainName',
},
'company-address-field-id': {
id: 'company-address-field-id',
name: 'address',
type: FieldMetadataType.TEXT,
label: 'address',
},
},
fieldIdByName: {
id: 'company-id-field-id',
name: 'company-name-field-id',
description: 'company-description-field-id',
domainName: 'company-domain-name-field-id',
address: 'company-address-field-id',
},
} as unknown as ObjectMetadataItemWithFieldMaps;
const objectMetadataMaps = {
byId: {
'person-object-metadata-id': personMetadata,
'company-object-metadata-id': companyMetadata,
},
idByNameSingular: {
person: 'person-object-metadata-id',
company: 'company-object-metadata-id',
},
} as ObjectMetadataMaps;
it('should return an empty object if no connect fields are found', () => {
const peopleEntityInputs = [
{
id: '1',
name: { lastName: 'Doe', firstName: 'John' },
},
{
id: '2',
name: { lastName: 'Doe', firstName: 'Jane' },
},
];
const result = computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
);
expect(result).toEqual({});
});
it('should throw an error if a connect field is not a relation field', () => {
const peopleEntityInputs = [
{
id: '1',
name: { connect: { where: { name: { lastName: 'Doe' } } } },
},
{
id: '2',
},
];
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
);
}).toThrow('Connect is not allowed for name on person');
});
it('should throw an error if connect field has not any unique constraint fully populated', () => {
const peopleEntityInputs = [
{
id: '1',
'company-related-to-1': {
connect: { where: { name: 'company1' } },
},
},
];
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
);
}).toThrow(
"Missing required fields: at least one unique constraint have to be fully populated for 'company-related-to-1'.",
);
});
it('should throw an error if connect field are not in constraint fields', () => {
const peopleEntityInputs = [
{
id: '1',
'company-related-to-1': {
connect: {
where: {
domainName: { primaryLinkUrl: 'company1.com' },
id: '1',
address: 'company1 address',
},
},
},
},
];
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
);
}).toThrow(
"Field address is not a unique constraint field for 'company-related-to-1'.",
);
});
it('should throw an error if connect field has different unique constraints populated', () => {
const peopleEntityInputs = [
{
id: '1',
'company-related-to-1': {
connect: {
where: {
domainName: { primaryLinkUrl: 'company1.com' },
},
},
},
},
{
id: '2',
'company-related-to-1': {
connect: {
where: { id: '2' },
},
},
},
];
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
);
}).toThrow(
'Expected the same constraint fields to be used consistently across all operations for company-related-to-1.',
);
});
it('should return the correct relation connect query configs', () => {
const peopleEntityInputs = [
{
id: '1',
'company-related-to-1': {
connect: {
where: {
domainName: { primaryLinkUrl: 'company.com' },
},
},
},
'company-related-to-2': {
connect: {
where: { id: '1' },
},
},
},
{
id: '2',
'company-related-to-1': {
connect: {
where: { domainName: { primaryLinkUrl: 'other-company.com' } },
},
},
'company-related-to-2': {
connect: {
where: { id: '2' },
},
},
},
];
const result = computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
);
expect(result).toEqual({
'company-related-to-1': {
connectFieldName: 'company-related-to-1',
recordToConnectConditions: [
[['domainNamePrimaryLinkUrl', 'company.com']],
[['domainNamePrimaryLinkUrl', 'other-company.com']],
],
recordToConnectConditionByEntityIndex: {
'0': [['domainNamePrimaryLinkUrl', 'company.com']],
'1': [['domainNamePrimaryLinkUrl', 'other-company.com']],
},
relationFieldName: 'company-related-to-1Id',
targetObjectName: 'company',
uniqueConstraintFields: [
{
id: 'company-domain-name-field-id',
label: 'domainName',
name: 'domainName',
type: FieldMetadataType.LINKS,
},
],
},
'company-related-to-2': {
connectFieldName: 'company-related-to-2',
recordToConnectConditions: [[['id', '1']], [['id', '2']]],
recordToConnectConditionByEntityIndex: {
'0': [['id', '1']],
'1': [['id', '2']],
},
relationFieldName: 'company-related-to-2Id',
targetObjectName: 'company',
uniqueConstraintFields: [
{
id: 'company-id-field-id',
label: 'id',
name: 'id',
type: FieldMetadataType.UUID,
},
],
},
});
});
});

View File

@ -0,0 +1,29 @@
import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils';
describe('createSqlWhereTupleInClause', () => {
it('should create a valid SQL WHERE clause for a tuple IN clause', () => {
const conditions = [
[
['field1', 'value1'] as [string, string],
['field2', 'value2'] as [string, string],
],
[
['field1', 'value3'] as [string, string],
['field2', 'value4'] as [string, string],
],
];
const tableName = 'table_name';
const result = createSqlWhereTupleInClause(conditions, tableName);
expect(result.clause).toBe(
'(table_name.field1, table_name.field2) IN ((:value0_0, :value0_1), (:value1_0, :value1_1))',
);
expect(result.parameters).toEqual({
value0_0: 'value1',
value0_1: 'value2',
value1_0: 'value3',
value1_1: 'value4',
});
});
});

View File

@ -0,0 +1,30 @@
import {
RelationConnectQueryConfig,
UniqueConstraintCondition,
} from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import { getRecordToConnectFields } from 'src/engine/twenty-orm/utils/get-record-to-connect-fields.util';
describe('getRecordToConnectFields', () => {
it('should return the fields to connect', () => {
const connectQueryConfig = {
recordToConnectConditions: [
[
['field1', 'value1'],
['field2', 'value2'],
] as UniqueConstraintCondition,
],
targetObjectName: 'target',
relationFieldName: 'relationId',
connectFieldName: 'relation',
uniqueConstraintFields: [],
} as unknown as RelationConnectQueryConfig;
const result = getRecordToConnectFields(connectQueryConfig);
expect(result).toEqual([
'"target"."id"',
'"target"."field1"',
'"target"."field2"',
]);
});
});

View File

@ -0,0 +1,344 @@
import { t } from '@lingui/core/macro';
import deepEqual from 'deep-equal';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { ConnectObject } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import {
RelationConnectQueryConfig,
UniqueConstraintCondition,
} from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { formatCompositeField } from 'src/engine/twenty-orm/utils/format-data.util';
import { getAssociatedRelationFieldName } from 'src/engine/twenty-orm/utils/get-associated-relation-field-name.util';
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const computeRelationConnectQueryConfigs = (
entities: Record<string, unknown>[],
objectMetadata: ObjectMetadataItemWithFieldMaps,
objectMetadataMap: ObjectMetadataMaps,
) => {
const allConnectQueryConfigs: Record<string, RelationConnectQueryConfig> = {};
for (const [entityIndex, entity] of entities.entries()) {
const connectFields = extractConnectFields(entity);
if (connectFields.length === 0) {
continue;
}
for (const connectField of connectFields) {
const [connectFieldName, connectObject] = Object.entries(connectField)[0];
const {
recordToConnectCondition,
uniqueConstraintFields,
targetObjectNameSingular,
} = computeRecordToConnectCondition(
connectFieldName,
connectObject,
objectMetadata,
objectMetadataMap,
entity,
);
const connectQueryConfig = allConnectQueryConfigs[connectFieldName];
if (isDefined(connectQueryConfig)) {
checkUniqueConstraintsAreSameOrThrow(
connectQueryConfig,
uniqueConstraintFields,
);
allConnectQueryConfigs[connectFieldName] = updateConnectQueryConfigs(
connectQueryConfig,
recordToConnectCondition,
entityIndex,
);
} else {
allConnectQueryConfigs[connectFieldName] = createConnectQueryConfig(
connectFieldName,
recordToConnectCondition,
uniqueConstraintFields,
targetObjectNameSingular,
entityIndex,
);
}
}
}
return allConnectQueryConfigs;
};
const updateConnectQueryConfigs = (
connectQueryConfig: RelationConnectQueryConfig,
recordToConnectCondition: UniqueConstraintCondition,
entityIndex: number,
) => {
return {
...connectQueryConfig,
recordToConnectConditions: [
...connectQueryConfig.recordToConnectConditions,
recordToConnectCondition,
],
recordToConnectConditionByEntityIndex: {
...connectQueryConfig.recordToConnectConditionByEntityIndex,
[entityIndex]: recordToConnectCondition,
},
};
};
const createConnectQueryConfig = (
connectFieldName: string,
recordToConnectCondition: UniqueConstraintCondition,
uniqueConstraintFields: FieldMetadataInterface<FieldMetadataType>[],
targetObjectNameSingular: string,
entityIndex: number,
) => {
return {
targetObjectName: targetObjectNameSingular,
recordToConnectConditions: [recordToConnectCondition],
relationFieldName: getAssociatedRelationFieldName(connectFieldName),
connectFieldName,
uniqueConstraintFields,
recordToConnectConditionByEntityIndex: {
[entityIndex]: recordToConnectCondition,
},
};
};
const computeRecordToConnectCondition = (
connectFieldName: string,
connectObject: ConnectObject,
objectMetadata: ObjectMetadataItemWithFieldMaps,
objectMetadataMap: ObjectMetadataMaps,
entity: Record<string, unknown>,
): {
recordToConnectCondition: UniqueConstraintCondition;
uniqueConstraintFields: FieldMetadataInterface<FieldMetadataType>[];
targetObjectNameSingular: string;
} => {
const field =
objectMetadata.fieldsById[objectMetadata.fieldIdByName[connectFieldName]];
if (
!isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION) ||
field.settings?.relationType !== RelationType.MANY_TO_ONE
) {
const objectMetadataNameSingular = objectMetadata.nameSingular;
throw new TwentyORMException(
`Connect is not allowed for ${connectFieldName} on ${objectMetadata.nameSingular}`,
TwentyORMExceptionCode.CONNECT_NOT_ALLOWED,
{
userFriendlyMessage: t`Connect is not allowed for ${connectFieldName} on ${objectMetadataNameSingular}`,
},
);
}
checkNoRelationFieldConflictOrThrow(entity, connectFieldName);
const targetObjectMetadata =
objectMetadataMap.byId[field.relationTargetObjectMetadataId || ''];
if (!isDefined(targetObjectMetadata)) {
throw new TwentyORMException(
`Target object metadata not found for ${connectFieldName}`,
TwentyORMExceptionCode.MALFORMED_METADATA,
{
userFriendlyMessage: t`Target object metadata not found for ${connectFieldName}`,
},
);
}
const uniqueConstraintFields = checkUniqueConstraintFullyPopulated(
targetObjectMetadata,
connectObject,
connectFieldName,
);
return {
recordToConnectCondition: computeUniqueConstraintCondition(
uniqueConstraintFields,
connectObject,
),
uniqueConstraintFields,
targetObjectNameSingular: targetObjectMetadata.nameSingular,
};
};
const extractConnectFields = (
entity: Record<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,
connectFieldName: string,
) => {
const uniqueConstraintsFields = getUniqueConstraintsFields({
...objectMetadata,
fields: Object.values(objectMetadata.fieldsById),
});
const hasUniqueConstraintFieldFullyPopulated = uniqueConstraintsFields.some(
(uniqueConstraintFields) =>
uniqueConstraintFields.every((uniqueConstraintField) =>
isDefined(connectObject.connect.where[uniqueConstraintField.name]),
),
);
if (!hasUniqueConstraintFieldFullyPopulated) {
throw new TwentyORMException(
`Missing required fields: at least one unique constraint have to be fully populated for '${connectFieldName}'.`,
TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR,
{
userFriendlyMessage: t`Missing required fields: at least one unique constraint have to be fully populated for '${connectFieldName}'.`,
},
);
}
return Object.keys(connectObject.connect.where).map((key) => {
const field = uniqueConstraintsFields
.flat()
.find((uniqueConstraintField) => uniqueConstraintField.name === key);
if (!isDefined(field)) {
throw new TwentyORMException(
`Field ${key} is not a unique constraint field for '${connectFieldName}'.`,
TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR,
);
}
return field;
});
};
const checkNoRelationFieldConflictOrThrow = (
entity: Record<string, unknown>,
fieldName: string,
) => {
const hasRelationFieldConflict =
isDefined(entity[fieldName]) && isDefined(entity[`${fieldName}Id`]);
if (hasRelationFieldConflict) {
throw new TwentyORMException(
`${fieldName} and ${fieldName}Id cannot be both provided.`,
TwentyORMExceptionCode.CONNECT_NOT_ALLOWED,
{
userFriendlyMessage: t`${fieldName} and ${fieldName}Id cannot be both provided.`,
},
);
}
};
const computeUniqueConstraintCondition = (
uniqueConstraintFields: FieldMetadataInterface<FieldMetadataType>[],
connectObject: ConnectObject,
): UniqueConstraintCondition => {
return uniqueConstraintFields.reduce((acc, uniqueConstraintField) => {
if (isCompositeFieldMetadataType(uniqueConstraintField.type)) {
return [
...acc,
...Object.entries(
formatCompositeField(
connectObject.connect.where[uniqueConstraintField.name],
uniqueConstraintField,
),
),
];
}
return [
...acc,
[
uniqueConstraintField.name,
connectObject.connect.where[uniqueConstraintField.name],
],
];
}, []);
};
const checkUniqueConstraintsAreSameOrThrow = (
relationConnectQueryConfig: RelationConnectQueryConfig,
uniqueConstraintFields: FieldMetadataInterface<FieldMetadataType>[],
) => {
if (
!deepEqual(
relationConnectQueryConfig.uniqueConstraintFields,
uniqueConstraintFields,
)
) {
const connectFieldName = relationConnectQueryConfig.connectFieldName;
throw new TwentyORMException(
`Expected the same constraint fields to be used consistently across all operations for ${relationConnectQueryConfig.connectFieldName}.`,
TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR,
{
userFriendlyMessage: t`Expected the same constraint fields to be used consistently across all operations for ${connectFieldName}.`,
},
);
}
};

View File

@ -0,0 +1,31 @@
export const createSqlWhereTupleInClause = (
conditions: [string, string][][],
tableName: string,
) => {
const fieldNames = conditions[0].map(([field, _]) => field);
const tupleClause = fieldNames
.map((field) => `${tableName}.${field}`)
.join(', ');
const valuePlaceholders = conditions
.map((_, index) => {
const placeholders = fieldNames.map(
(_, fieldIndex) => `:value${index}_${fieldIndex}`,
);
return `(${placeholders.join(', ')})`;
})
.join(', ');
const clause = `(${tupleClause}) IN (${valuePlaceholders})`;
const parameters: Record<string, string> = {};
conditions.forEach((condition, conditionIndex) => {
condition.forEach(([_, value], fieldIndex) => {
parameters[`value${conditionIndex}_${fieldIndex}`] = value;
});
});
return { clause, parameters };
};

View File

@ -54,7 +54,7 @@ export function formatData<T>(
return newData as T;
}
function formatCompositeField(
export function formatCompositeField(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
fieldMetadata: FieldMetadataInterface,

View File

@ -0,0 +1,2 @@
export const getAssociatedRelationFieldName = (connectFieldName: string) =>
`${connectFieldName}Id`;

View File

@ -0,0 +1,49 @@
import { EntitySchema, EntityTarget, ObjectLiteral } from 'typeorm';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage';
export const getObjectMetadataFromEntityTarget = <T extends ObjectLiteral>(
entityTarget: EntityTarget<T>,
internalContext: WorkspaceInternalContext,
) => {
const objectMetadataName =
typeof entityTarget === 'string'
? entityTarget
: WorkspaceEntitiesStorage.getObjectMetadataName(
internalContext.workspaceId,
entityTarget as EntitySchema,
);
if (!objectMetadataName) {
throw new TwentyORMException(
'Object metadata name is missing',
TwentyORMExceptionCode.MALFORMED_METADATA,
);
}
const objectMetadata = getObjectMetadataMapItemByNameSingular(
internalContext.objectMetadataMaps,
objectMetadataName,
);
if (!objectMetadata) {
throw new TwentyORMException(
`Object metadata for object "${objectMetadataName}" is missing ` +
`in workspace "${internalContext.workspaceId}" ` +
`with object metadata collection length: ${
Object.keys(internalContext.objectMetadataMaps.idByNameSingular)
.length
}`,
TwentyORMExceptionCode.MALFORMED_METADATA,
);
}
return objectMetadata;
};

View File

@ -0,0 +1,12 @@
import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
export const getRecordToConnectFields = (
connectQueryConfig: RelationConnectQueryConfig,
) => {
return [
`"${connectQueryConfig.targetObjectName}"."id"`,
...connectQueryConfig.recordToConnectConditions[0].map(([field]) => {
return `"${connectQueryConfig.targetObjectName}"."${field}"`;
}),
];
};

View File

@ -0,0 +1,21 @@
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
export const twentyORMGraphqlApiExceptionHandler = (
error: TwentyORMException,
) => {
switch (error.code) {
case TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND:
case TwentyORMExceptionCode.CONNECT_NOT_ALLOWED:
case TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
throw error;
}
}
};

View File

@ -1 +1,2 @@
export const TEST_COMPANY_1_ID = '525c282e-030a-4a3e-90a0-d8aad0d33a93';
export const TEST_COMPANY_2_ID = '2fd9a9ea-04a5-483b-846d-2819dd658fc1';

View File

@ -0,0 +1,222 @@
import {
TEST_COMPANY_1_ID,
TEST_COMPANY_2_ID,
} from 'test/integration/constants/test-company-ids.constants';
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
const PERSON_GQL_FIELDS_WITH_COMPANY = `
id
company {
id
}
`;
describe('relation connect in workspace createOne/createMany resolvers (e2e)', () => {
beforeAll(async () => {
const graphqlOperation = createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: `id`,
data: [
{
id: TEST_COMPANY_1_ID,
domainName: { primaryLinkUrl: 'company1.com' },
},
{
id: TEST_COMPANY_2_ID,
domainName: { primaryLinkUrl: 'company2.com' },
},
],
});
await makeGraphqlAPIRequest(graphqlOperation);
});
beforeEach(async () => {
await deleteAllRecords('person');
});
afterAll(async () => {
await deleteAllRecords('company');
await deleteAllRecords('person');
});
it('should connect to other records through a MANY-TO-ONE relation - create One', async () => {
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
data: {
id: TEST_PERSON_1_ID,
company: {
connect: {
where: { domainName: { primaryLinkUrl: 'company1.com' } },
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.createPerson).toBeDefined();
expect(response.body.data.createPerson.id).toBe(TEST_PERSON_1_ID);
expect(response.body.data.createPerson.company.id).toBe(TEST_COMPANY_1_ID);
});
it('should connect to other records through a MANY-TO-ONE relation - create Many', async () => {
const graphqlOperation = createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
data: [
{
id: TEST_PERSON_1_ID,
company: {
connect: {
where: { domainName: { primaryLinkUrl: 'company1.com' } },
},
},
},
{
id: TEST_PERSON_2_ID,
company: {
connect: {
where: { domainName: { primaryLinkUrl: 'company2.com' } },
},
},
},
],
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.createPeople).toBeDefined();
expect(response.body.data.createPeople).toHaveLength(2);
expect(response.body.data.createPeople[0].company.id).toBe(
TEST_COMPANY_1_ID,
);
expect(response.body.data.createPeople[1].company.id).toBe(
TEST_COMPANY_2_ID,
);
});
it('should throw an error if relation id field and relation connect field are both provided', async () => {
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
data: {
id: TEST_PERSON_1_ID,
companyId: TEST_COMPANY_1_ID,
company: {
connect: {
where: { domainName: { primaryLinkUrl: 'company1.com' } },
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'company and companyId cannot be both provided.',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should throw an error if record to connect to does not exist', async () => {
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
data: {
id: TEST_PERSON_1_ID,
company: {
connect: {
where: { domainName: { primaryLinkUrl: 'not-existing-company' } },
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'Expected 1 record to connect to company, but found 0.',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should throw an error if unique constraint is not the same for all created records', async () => {
const graphqlOperation = createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
data: [
{
id: TEST_PERSON_1_ID,
company: {
connect: {
where: { domainName: { primaryLinkUrl: 'company1.com' } },
},
},
},
{
id: TEST_PERSON_2_ID,
company: {
connect: {
where: { id: TEST_COMPANY_2_ID },
},
},
},
],
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'Expected the same constraint fields to be used consistently across all operations for company.',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should throw an error if connect field is not set with field from unique constraint', async () => {
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS_WITH_COMPANY,
data: {
id: TEST_PERSON_1_ID,
company: {
connect: {
where: { name: 'company1' },
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'Field "name" is not defined by type "CompanyWhereUniqueInput".',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
});