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_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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -39,11 +39,6 @@ export class RelationConnectInputTypeDefinitionFactory {
|
||||
kind: InputTypeDefinitionKind.Create,
|
||||
type: fields,
|
||||
},
|
||||
{
|
||||
target,
|
||||
kind: InputTypeDefinitionKind.Update,
|
||||
type: fields,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -81,9 +81,6 @@ export class WorkspaceSchemaFactory {
|
||||
await this.workspaceGraphQLSchemaFactory.create(
|
||||
objectMetadataCollection,
|
||||
workspaceResolverBuilderMethodNames,
|
||||
{
|
||||
workspaceId: authContext.workspace.id,
|
||||
},
|
||||
);
|
||||
|
||||
usedScalarNames =
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
} 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
function formatCompositeField(
|
||||
export function formatCompositeField(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any,
|
||||
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_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