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:
@ -683,7 +683,6 @@ export enum FeatureFlagKey {
|
|||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_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_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
|
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
|
||||||
|
|||||||
@ -647,7 +647,6 @@ export enum FeatureFlagKey {
|
|||||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_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_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
|
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
|
||||||
|
|||||||
@ -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 { 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 { 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 { 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 {
|
interface QueryFailedErrorWithCode extends QueryFailedError {
|
||||||
code: string;
|
code: string;
|
||||||
@ -44,6 +46,8 @@ export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
|
|||||||
return workspaceExceptionHandler(error);
|
return workspaceExceptionHandler(error);
|
||||||
case error instanceof GraphqlQueryRunnerException:
|
case error instanceof GraphqlQueryRunnerException:
|
||||||
return graphqlQueryRunnerExceptionHandler(error);
|
return graphqlQueryRunnerExceptionHandler(error);
|
||||||
|
case error instanceof TwentyORMException:
|
||||||
|
return twentyORMGraphqlApiExceptionHandler(error);
|
||||||
default:
|
default:
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,12 +36,10 @@ export class InputTypeDefinitionFactory {
|
|||||||
objectMetadata,
|
objectMetadata,
|
||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
isRelationConnectEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
objectMetadata: ObjectMetadataInterface;
|
objectMetadata: ObjectMetadataInterface;
|
||||||
kind: InputTypeDefinitionKind;
|
kind: InputTypeDefinitionKind;
|
||||||
options: WorkspaceBuildSchemaOptions;
|
options: WorkspaceBuildSchemaOptions;
|
||||||
isRelationConnectEnabled?: boolean;
|
|
||||||
}): InputTypeDefinition {
|
}): InputTypeDefinition {
|
||||||
// @ts-expect-error legacy noImplicitAny
|
// @ts-expect-error legacy noImplicitAny
|
||||||
const inputType = new GraphQLInputObjectType({
|
const inputType = new GraphQLInputObjectType({
|
||||||
@ -89,7 +87,6 @@ export class InputTypeDefinitionFactory {
|
|||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
typeFactory: this.inputTypeFactory,
|
typeFactory: this.inputTypeFactory,
|
||||||
isRelationConnectEnabled,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -39,11 +39,6 @@ export class RelationConnectInputTypeDefinitionFactory {
|
|||||||
kind: InputTypeDefinitionKind.Create,
|
kind: InputTypeDefinitionKind.Create,
|
||||||
type: fields,
|
type: fields,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
target,
|
|
||||||
kind: InputTypeDefinitionKind.Update,
|
|
||||||
type: fields,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,10 +13,4 @@ export interface WorkspaceBuildSchemaOptions {
|
|||||||
* @default 'float'
|
* @default 'float'
|
||||||
*/
|
*/
|
||||||
numberScalarMode?: NumberScalarMode;
|
numberScalarMode?: NumberScalarMode;
|
||||||
|
|
||||||
/**
|
|
||||||
* Workspace ID - used to relation connect feature flag check
|
|
||||||
* TODO: remove once IS_RELATION_CONNECT_ENABLED is removed
|
|
||||||
*/
|
|
||||||
workspaceId?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
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 { 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';
|
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 { 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 { 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 { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||||
|
|
||||||
@ -213,13 +210,6 @@ export class TypeDefinitionsGenerator {
|
|||||||
objectMetadataCollection: ObjectMetadataInterface[],
|
objectMetadataCollection: ObjectMetadataInterface[],
|
||||||
options: WorkspaceBuildSchemaOptions,
|
options: WorkspaceBuildSchemaOptions,
|
||||||
) {
|
) {
|
||||||
const isRelationConnectEnabled = isDefined(options.workspaceId)
|
|
||||||
? await this.featureFlagService.isFeatureEnabled(
|
|
||||||
FeatureFlagKey.IS_RELATION_CONNECT_ENABLED,
|
|
||||||
options.workspaceId,
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const inputTypeDefs = objectMetadataCollection
|
const inputTypeDefs = objectMetadataCollection
|
||||||
.map((objectMetadata) => {
|
.map((objectMetadata) => {
|
||||||
const optionalExtendedObjectMetadata = {
|
const optionalExtendedObjectMetadata = {
|
||||||
@ -236,13 +226,11 @@ export class TypeDefinitionsGenerator {
|
|||||||
objectMetadata,
|
objectMetadata,
|
||||||
kind: InputTypeDefinitionKind.Create,
|
kind: InputTypeDefinitionKind.Create,
|
||||||
options,
|
options,
|
||||||
isRelationConnectEnabled,
|
|
||||||
}),
|
}),
|
||||||
// Input type for update
|
// Input type for update
|
||||||
this.inputTypeDefinitionFactory.create({
|
this.inputTypeDefinitionFactory.create({
|
||||||
objectMetadata: optionalExtendedObjectMetadata,
|
objectMetadata: optionalExtendedObjectMetadata,
|
||||||
kind: InputTypeDefinitionKind.Update,
|
kind: InputTypeDefinitionKind.Update,
|
||||||
isRelationConnectEnabled,
|
|
||||||
options,
|
options,
|
||||||
}),
|
}),
|
||||||
// Filter input type
|
// Filter input type
|
||||||
|
|||||||
@ -47,13 +47,11 @@ export const generateFields = <
|
|||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
typeFactory,
|
typeFactory,
|
||||||
isRelationConnectEnabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
objectMetadata: ObjectMetadataInterface;
|
objectMetadata: ObjectMetadataInterface;
|
||||||
kind: T;
|
kind: T;
|
||||||
options: WorkspaceBuildSchemaOptions;
|
options: WorkspaceBuildSchemaOptions;
|
||||||
typeFactory: TypeFactory<T>;
|
typeFactory: TypeFactory<T>;
|
||||||
isRelationConnectEnabled?: boolean;
|
|
||||||
}): T extends InputTypeDefinitionKind
|
}): T extends InputTypeDefinitionKind
|
||||||
? GraphQLInputFieldConfigMap
|
? GraphQLInputFieldConfigMap
|
||||||
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -80,7 +78,6 @@ export const generateFields = <
|
|||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
typeFactory,
|
typeFactory,
|
||||||
isRelationConnectEnabled,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
generatedField = generateField({
|
generatedField = generateField({
|
||||||
@ -168,7 +165,6 @@ const generateRelationField = <
|
|||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
typeFactory,
|
typeFactory,
|
||||||
isRelationConnectEnabled,
|
|
||||||
}: {
|
}: {
|
||||||
fieldMetadata: FieldMetadataInterface<
|
fieldMetadata: FieldMetadataInterface<
|
||||||
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||||
@ -176,7 +172,6 @@ const generateRelationField = <
|
|||||||
kind: T;
|
kind: T;
|
||||||
options: WorkspaceBuildSchemaOptions;
|
options: WorkspaceBuildSchemaOptions;
|
||||||
typeFactory: TypeFactory<T>;
|
typeFactory: TypeFactory<T>;
|
||||||
isRelationConnectEnabled: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const relationField = {};
|
const relationField = {};
|
||||||
|
|
||||||
@ -208,11 +203,10 @@ const generateRelationField = <
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
[InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes(
|
[InputTypeDefinitionKind.Create].includes(
|
||||||
kind as InputTypeDefinitionKind,
|
kind as InputTypeDefinitionKind,
|
||||||
) &&
|
) &&
|
||||||
isDefined(fieldMetadata.relationTargetObjectMetadataId) &&
|
isDefined(fieldMetadata.relationTargetObjectMetadataId)
|
||||||
isRelationConnectEnabled
|
|
||||||
) {
|
) {
|
||||||
type = typeFactory.create(
|
type = typeFactory.create(
|
||||||
formatRelationConnectInputTarget(
|
formatRelationConnectInputTarget(
|
||||||
|
|||||||
@ -81,9 +81,6 @@ export class WorkspaceSchemaFactory {
|
|||||||
await this.workspaceGraphQLSchemaFactory.create(
|
await this.workspaceGraphQLSchemaFactory.create(
|
||||||
objectMetadataCollection,
|
objectMetadataCollection,
|
||||||
workspaceResolverBuilderMethodNames,
|
workspaceResolverBuilderMethodNames,
|
||||||
{
|
|
||||||
workspaceId: authContext.workspace.id,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
usedScalarNames =
|
usedScalarNames =
|
||||||
|
|||||||
@ -8,6 +8,5 @@ export enum FeatureFlagKey {
|
|||||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_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',
|
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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];
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -37,12 +37,22 @@ import {
|
|||||||
PermissionsExceptionCode,
|
PermissionsExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||||
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
|
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 {
|
import {
|
||||||
OperationType,
|
OperationType,
|
||||||
validateOperationIsPermittedOrThrow,
|
validateOperationIsPermittedOrThrow,
|
||||||
} from 'src/engine/twenty-orm/repository/permissions.utils';
|
} from 'src/engine/twenty-orm/repository/permissions.utils';
|
||||||
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
|
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
|
||||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
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 = {
|
type PermissionOptions = {
|
||||||
shouldBypassPermissionChecks?: boolean;
|
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>,
|
target: EntityTarget<Entity>,
|
||||||
entity: QueryDeepPartialEntity<Entity> | QueryDeepPartialEntity<Entity>[],
|
entity:
|
||||||
|
| QueryDeepPartialEntityWithRelationConnect<Entity>
|
||||||
|
| QueryDeepPartialEntityWithRelationConnect<Entity>[],
|
||||||
permissionOptions?: PermissionOptions,
|
permissionOptions?: PermissionOptions,
|
||||||
): Promise<InsertResult> {
|
): Promise<InsertResult> {
|
||||||
|
const entityArray = Array.isArray(entity) ? entity : [entity];
|
||||||
|
|
||||||
|
const connectedEntities = await this.processRelationConnect<Entity>(
|
||||||
|
entityArray,
|
||||||
|
target,
|
||||||
|
permissionOptions,
|
||||||
|
);
|
||||||
|
|
||||||
return this.createQueryBuilder(
|
return this.createQueryBuilder(
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@ -178,7 +198,7 @@ export class WorkspaceEntityManager extends EntityManager {
|
|||||||
)
|
)
|
||||||
.insert()
|
.insert()
|
||||||
.into(target)
|
.into(target)
|
||||||
.values(entity)
|
.values(connectedEntities)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1321,4 +1341,118 @@ export class WorkspaceEntityManager extends EntityManager {
|
|||||||
PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED,
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import { CustomException } from 'src/utils/custom-exception';
|
import { CustomException } from 'src/utils/custom-exception';
|
||||||
|
|
||||||
export class TwentyORMException extends CustomException {
|
export class TwentyORMException extends CustomException {
|
||||||
constructor(message: string, code: TwentyORMExceptionCode) {
|
constructor(
|
||||||
super(message, code);
|
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',
|
USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND = 'USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND',
|
||||||
MALFORMED_METADATA = 'MALFORMED_METADATA',
|
MALFORMED_METADATA = 'MALFORMED_METADATA',
|
||||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
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',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { ObjectRecordsPermissions } from 'twenty-shared/types';
|
|||||||
import {
|
import {
|
||||||
DeepPartial,
|
DeepPartial,
|
||||||
DeleteResult,
|
DeleteResult,
|
||||||
EntitySchema,
|
|
||||||
EntityTarget,
|
EntityTarget,
|
||||||
FindManyOptions,
|
FindManyOptions,
|
||||||
FindOneOptions,
|
FindOneOptions,
|
||||||
@ -28,12 +27,12 @@ import {
|
|||||||
PermissionsExceptionCode,
|
PermissionsExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
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 { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||||
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
|
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 { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
|
||||||
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.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<
|
export class WorkspaceRepository<
|
||||||
T extends ObjectLiteral,
|
T extends ObjectLiteral,
|
||||||
@ -552,7 +551,9 @@ export class WorkspaceRepository<
|
|||||||
* INSERT METHODS
|
* INSERT METHODS
|
||||||
*/
|
*/
|
||||||
override async insert(
|
override async insert(
|
||||||
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
|
entity:
|
||||||
|
| QueryDeepPartialEntityWithRelationConnect<T>
|
||||||
|
| QueryDeepPartialEntityWithRelationConnect<T>[],
|
||||||
entityManager?: WorkspaceEntityManager,
|
entityManager?: WorkspaceEntityManager,
|
||||||
): Promise<InsertResult> {
|
): Promise<InsertResult> {
|
||||||
const manager = entityManager || this.manager;
|
const manager = entityManager || this.manager;
|
||||||
@ -913,36 +914,7 @@ export class WorkspaceRepository<
|
|||||||
* PRIVATE METHODS
|
* PRIVATE METHODS
|
||||||
*/
|
*/
|
||||||
private async getObjectMetadataFromTarget() {
|
private async getObjectMetadataFromTarget() {
|
||||||
const objectMetadataName =
|
return getObjectMetadataFromEntityTarget(this.target, this.internalContext);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async transformOptions<
|
private async transformOptions<
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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"',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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}.`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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 };
|
||||||
|
};
|
||||||
@ -54,7 +54,7 @@ export function formatData<T>(
|
|||||||
return newData as T;
|
return newData as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCompositeField(
|
export function formatCompositeField(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
value: any,
|
value: any,
|
||||||
fieldMetadata: FieldMetadataInterface,
|
fieldMetadata: FieldMetadataInterface,
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
export const getAssociatedRelationFieldName = (connectFieldName: string) =>
|
||||||
|
`${connectFieldName}Id`;
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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}"`;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1 +1,2 @@
|
|||||||
export const TEST_COMPANY_1_ID = '525c282e-030a-4a3e-90a0-d8aad0d33a93';
|
export const TEST_COMPANY_1_ID = '525c282e-030a-4a3e-90a0-d8aad0d33a93';
|
||||||
|
export const TEST_COMPANY_2_ID = '2fd9a9ea-04a5-483b-846d-2819dd658fc1';
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user