Connect/Disconnect - Add Disconnect logic + Migration to query builders (insert/update) (#13271)

Context : 
Large PR with 600+ test files. Enable connect and disconnect logic in
createMany (upsert true) / updateOne / updateMany resolvers

- Add disconnect logic
- Gather disconnect and connect logic -> called relation nested queries
- Move logic to query builder (insert and update one) with a preparation
step in .set/.values and an execution step in .execute
- Add integration tests

Test : 
- Test API call on updateMany, updateOne, createMany (upsert:true) with
connect/disconnect
This commit is contained in:
Etienne
2025-07-24 17:04:38 +02:00
committed by GitHub
parent 7bfa003682
commit 88a6913217
20 changed files with 1182 additions and 445 deletions

View File

@ -1,3 +1,4 @@
import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
@ -7,20 +8,24 @@ export type ConnectWhereValue = string | Record<string, string>;
export type ConnectWhere = Record<string, ConnectWhereValue>;
export type ConnectObject = {
connect: {
where: ConnectWhere;
[RELATION_NESTED_QUERY_KEYWORDS.CONNECT]: {
[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE]: ConnectWhere;
};
};
export type DisconnectObject = {
[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT]: true;
};
type EntityRelationFields<T> = {
[K in keyof T]: T[K] extends BaseWorkspaceEntity | null ? K : never;
}[keyof T];
export type QueryDeepPartialEntityWithRelationConnect<T> = Omit<
export type QueryDeepPartialEntityWithNestedRelationFields<T> = Omit<
QueryDeepPartialEntity<T>,
EntityRelationFields<T>
> & {
[K in keyof T]?: T[K] extends BaseWorkspaceEntity | null
? T[K] | ConnectObject
? T[K] | ConnectObject | DisconnectObject
: T[K];
};

View File

@ -0,0 +1,14 @@
import {
ConnectObject,
DisconnectObject,
} from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
export type RelationConnectQueryFieldsByEntityIndex = {
[entityIndex: string]: { [key: string]: ConnectObject };
};
export type RelationDisconnectQueryFieldsByEntityIndex = {
[entityIndex: string]: {
[key: string]: DisconnectObject;
};
};

View File

@ -40,24 +40,16 @@ import {
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { QueryDeepPartialEntityWithRelationConnect } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import {
OperationType,
validateOperationIsPermittedOrThrow,
} from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util';
import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { getRecordToConnectFields } from 'src/engine/twenty-orm/utils/get-record-to-connect-fields.util';
type PermissionOptions = {
shouldBypassPermissionChecks?: boolean;
@ -172,19 +164,11 @@ export class WorkspaceEntityManager extends EntityManager {
override async insert<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
entity:
| QueryDeepPartialEntityWithRelationConnect<Entity>
| QueryDeepPartialEntityWithRelationConnect<Entity>[],
| QueryDeepPartialEntityWithNestedRelationFields<Entity>
| QueryDeepPartialEntityWithNestedRelationFields<Entity>[],
selectedColumns: string[] = [],
permissionOptions?: PermissionOptions,
): Promise<InsertResult> {
const entityArray = Array.isArray(entity) ? entity : [entity];
const connectedEntities = await this.processRelationConnect<Entity>(
entityArray,
target,
permissionOptions,
);
return this.createQueryBuilder(
undefined,
undefined,
@ -193,7 +177,7 @@ export class WorkspaceEntityManager extends EntityManager {
)
.insert()
.into(target)
.values(connectedEntities)
.values(entity)
.returning(selectedColumns)
.execute();
}
@ -1484,118 +1468,4 @@ export class WorkspaceEntityManager extends EntityManager {
PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED,
);
}
private async processRelationConnect<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithRelationConnect<Entity>[],
target: EntityTarget<Entity>,
permissionOptions?: PermissionOptions,
): Promise<QueryDeepPartialEntity<Entity>[]> {
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const objectMetadataMap = this.internalContext.objectMetadataMaps;
const relationConnectQueryConfigs = computeRelationConnectQueryConfigs(
entities,
objectMetadata,
objectMetadataMap,
);
if (!isDefined(relationConnectQueryConfigs)) return entities;
const recordsToConnectWithConfig = await this.executeConnectQueries(
relationConnectQueryConfigs,
permissionOptions,
);
const updatedEntities = this.updateEntitiesWithRecordToConnectId<Entity>(
entities,
recordsToConnectWithConfig,
);
return updatedEntities;
}
private async executeConnectQueries(
relationConnectQueryConfigs: Record<string, RelationConnectQueryConfig>,
permissionOptions?: PermissionOptions,
): Promise<[RelationConnectQueryConfig, Record<string, unknown>[]][]> {
const AllRecordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][] = [];
for (const connectQueryConfig of Object.values(
relationConnectQueryConfigs,
)) {
const { clause, parameters } = createSqlWhereTupleInClause(
connectQueryConfig.recordToConnectConditions,
connectQueryConfig.targetObjectName,
);
const recordsToConnect = await this.createQueryBuilder(
connectQueryConfig.targetObjectName,
connectQueryConfig.targetObjectName,
undefined,
permissionOptions,
)
.select(getRecordToConnectFields(connectQueryConfig))
.where(clause, parameters)
.getRawMany();
AllRecordsToConnectWithConfig.push([
connectQueryConfig,
recordsToConnect,
]);
}
return AllRecordsToConnectWithConfig;
}
private updateEntitiesWithRecordToConnectId<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithRelationConnect<Entity>[],
recordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][],
): QueryDeepPartialEntity<Entity>[] {
return entities.map((entity, index) => {
for (const [
connectQueryConfig,
recordsToConnect,
] of recordsToConnectWithConfig) {
if (
isDefined(
connectQueryConfig.recordToConnectConditionByEntityIndex[index],
)
) {
const recordToConnect = recordsToConnect.filter((record) =>
connectQueryConfig.recordToConnectConditionByEntityIndex[
index
].every(([field, value]) => record[field] === value),
);
if (recordToConnect.length !== 1) {
const recordToConnectTotal = recordToConnect.length;
const connectFieldName = connectQueryConfig.connectFieldName;
throw new TwentyORMException(
`Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal}.`,
TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND,
);
}
entity = {
...entity,
[connectQueryConfig.relationFieldName]: recordToConnect[0]['id'],
[connectQueryConfig.connectFieldName]: null,
};
}
}
return entity;
});
}
}

View File

@ -0,0 +1,249 @@
import { isDefined } from 'class-validator';
import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import {
RelationConnectQueryFieldsByEntityIndex,
RelationDisconnectQueryFieldsByEntityIndex,
} from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util';
import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils';
import { extractNestedRelationFieldsByEntityIndex } from 'src/engine/twenty-orm/utils/extract-nested-relation-fields-by-entity-index.util';
import { getAssociatedRelationFieldName } from 'src/engine/twenty-orm/utils/get-associated-relation-field-name.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { getRecordToConnectFields } from 'src/engine/twenty-orm/utils/get-record-to-connect-fields.util';
export class RelationNestedQueries {
private readonly internalContext: WorkspaceInternalContext;
constructor(internalContext: WorkspaceInternalContext) {
this.internalContext = internalContext;
}
prepareNestedRelationQueries<Entity extends ObjectLiteral>(
entities:
| QueryDeepPartialEntityWithNestedRelationFields<Entity>[]
| QueryDeepPartialEntityWithNestedRelationFields<Entity>,
target: EntityTarget<Entity>,
): [
RelationConnectQueryConfig[],
RelationDisconnectQueryFieldsByEntityIndex,
] {
const entitiesArray = Array.isArray(entities) ? entities : [entities];
const {
relationConnectQueryFieldsByEntityIndex,
relationDisconnectQueryFieldsByEntityIndex,
} = extractNestedRelationFieldsByEntityIndex(entitiesArray);
const connectConfig = this.prepareRelationConnect(
entitiesArray,
target,
relationConnectQueryFieldsByEntityIndex,
);
return [connectConfig, relationDisconnectQueryFieldsByEntityIndex];
}
private prepareRelationConnect<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[],
target: EntityTarget<Entity>,
relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex,
) {
const objectMetadata = getObjectMetadataFromEntityTarget(
target,
this.internalContext,
);
const objectMetadataMap = this.internalContext.objectMetadataMaps;
const relationConnectQueryConfigs = computeRelationConnectQueryConfigs(
entities,
objectMetadata,
objectMetadataMap,
relationConnectQueryFieldsByEntityIndex,
);
return relationConnectQueryConfigs;
}
async processRelationNestedQueries<Entity extends ObjectLiteral>({
entities,
relationNestedConfig,
queryBuilder,
}: {
entities:
| QueryDeepPartialEntityWithNestedRelationFields<Entity>[]
| QueryDeepPartialEntityWithNestedRelationFields<Entity>;
relationNestedConfig: [
RelationConnectQueryConfig[],
RelationDisconnectQueryFieldsByEntityIndex,
];
queryBuilder: WorkspaceSelectQueryBuilder<Entity>;
}): Promise<QueryDeepPartialEntity<Entity>[]> {
const entitiesArray = Array.isArray(entities) ? entities : [entities];
const [
relationConnectQueryConfigs,
relationDisconnectQueryFieldsByEntityIndex,
] = relationNestedConfig;
const updatedEntitiesWithDisconnect = this.processRelationDisconnect({
entities: entitiesArray,
relationDisconnectQueryFieldsByEntityIndex,
});
const updatedEntitiesWithConnect = await this.processRelationConnect({
entities: updatedEntitiesWithDisconnect,
relationConnectQueryConfigs,
queryBuilder,
});
return updatedEntitiesWithConnect;
}
private async processRelationConnect<Entity extends ObjectLiteral>({
entities,
relationConnectQueryConfigs,
queryBuilder,
}: {
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[];
relationConnectQueryConfigs: RelationConnectQueryConfig[];
queryBuilder: WorkspaceSelectQueryBuilder<Entity>;
}): Promise<QueryDeepPartialEntity<Entity>[]> {
if (relationConnectQueryConfigs.length === 0) return entities;
const recordsToConnectWithConfig = await this.executeConnectQueries(
relationConnectQueryConfigs,
queryBuilder,
);
const updatedEntities = this.updateEntitiesWithRecordToConnectId<Entity>(
entities,
recordsToConnectWithConfig,
);
return updatedEntities;
}
private async executeConnectQueries<Entity extends ObjectLiteral>(
relationConnectQueryConfigs: RelationConnectQueryConfig[],
queryBuilder: WorkspaceSelectQueryBuilder<Entity>,
): Promise<[RelationConnectQueryConfig, Record<string, unknown>[]][]> {
const allRecordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][] = [];
for (const connectQueryConfig of relationConnectQueryConfigs) {
const { clause, parameters } = createSqlWhereTupleInClause(
connectQueryConfig.recordToConnectConditions,
connectQueryConfig.targetObjectName,
);
queryBuilder.expressionMap.aliases = [];
queryBuilder.expressionMap.mainAlias = undefined;
const recordsToConnect = await queryBuilder
.select(getRecordToConnectFields(connectQueryConfig))
.where(clause, parameters)
.from(
connectQueryConfig.targetObjectName,
connectQueryConfig.targetObjectName,
)
.getRawMany();
allRecordsToConnectWithConfig.push([
connectQueryConfig,
recordsToConnect,
]);
}
return allRecordsToConnectWithConfig;
}
private updateEntitiesWithRecordToConnectId<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[],
recordsToConnectWithConfig: [
RelationConnectQueryConfig,
Record<string, unknown>[],
][],
): QueryDeepPartialEntity<Entity>[] {
return entities.map((entity, index) => {
for (const [
connectQueryConfig,
recordsToConnect,
] of recordsToConnectWithConfig) {
if (
isDefined(
connectQueryConfig.recordToConnectConditionByEntityIndex[index],
)
) {
const recordToConnect = recordsToConnect.filter((record) =>
connectQueryConfig.recordToConnectConditionByEntityIndex[
index
].every(([field, value]) => record[field] === value),
);
if (recordToConnect.length !== 1) {
const recordToConnectTotal = recordToConnect.length;
const connectFieldName = connectQueryConfig.connectFieldName;
throw new TwentyORMException(
`Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal}.`,
TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND,
);
}
entity = {
...entity,
[connectQueryConfig.relationFieldName]: recordToConnect[0]['id'],
[connectQueryConfig.connectFieldName]: null,
};
}
}
return entity;
});
}
private processRelationDisconnect<Entity extends ObjectLiteral>({
entities,
relationDisconnectQueryFieldsByEntityIndex,
}: {
entities: QueryDeepPartialEntityWithNestedRelationFields<Entity>[];
relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex;
}): QueryDeepPartialEntityWithNestedRelationFields<Entity>[] {
return entities.map((entity, index) => {
const nestedRelationDisconnectFields =
relationDisconnectQueryFieldsByEntityIndex[index];
if (!isDefined(nestedRelationDisconnectFields)) return entity;
for (const [disconnectFieldName, disconnectObject] of Object.entries(
nestedRelationDisconnectFields ?? {},
)) {
entity = {
...entity,
[disconnectFieldName]: null,
...(disconnectObject[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT] ===
true
? { [getAssociatedRelationFieldName(disconnectFieldName)]: null }
: {}),
};
}
return entity;
});
}
}

View File

@ -5,7 +5,6 @@ import {
InsertResult,
ObjectLiteral,
} from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
@ -13,10 +12,14 @@ import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/works
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import { RelationDisconnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
@ -34,6 +37,11 @@ export class WorkspaceInsertQueryBuilder<
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
private featureFlagMap?: FeatureFlagMap;
private relationNestedQueries: RelationNestedQueries;
private relationNestedConfig: [
RelationConnectQueryConfig[],
RelationDisconnectQueryFieldsByEntityIndex,
];
constructor(
queryBuilder: InsertQueryBuilder<T>,
@ -49,6 +57,9 @@ export class WorkspaceInsertQueryBuilder<
this.shouldBypassPermissionChecks = shouldBypassPermissionChecks;
this.authContext = authContext;
this.featureFlagMap = featureFlagMap;
this.relationNestedQueries = new RelationNestedQueries(
this.internalContext,
);
}
override clone(): this {
@ -65,10 +76,18 @@ export class WorkspaceInsertQueryBuilder<
}
override values(
values: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
values:
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
): this {
const mainAliasTarget = this.getMainAliasTarget();
this.relationNestedConfig =
this.relationNestedQueries.prepareNestedRelationQueries(
values,
mainAliasTarget,
);
const objectMetadata = getObjectMetadataFromEntityTarget(
mainAliasTarget,
this.internalContext,
@ -96,6 +115,25 @@ export class WorkspaceInsertQueryBuilder<
this.internalContext,
);
const nestedRelationQueryBuilder = new WorkspaceSelectQueryBuilder(
this as unknown as WorkspaceSelectQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
);
const updatedValues =
await this.relationNestedQueries.processRelationNestedQueries({
entities: this.expressionMap.valuesSet as
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
relationNestedConfig: this.relationNestedConfig,
queryBuilder: nestedRelationQueryBuilder,
});
this.expressionMap.valuesSet = updatedValues;
const result = await super.execute();
const formattedResult = formatResult<T[]>(

View File

@ -13,10 +13,14 @@ import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/works
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { RelationConnectQueryConfig } from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import { RelationDisconnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
@ -33,6 +37,12 @@ export class WorkspaceUpdateQueryBuilder<
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
private featureFlagMap?: FeatureFlagMap;
private relationNestedQueries: RelationNestedQueries;
private relationNestedConfig: [
RelationConnectQueryConfig[],
RelationDisconnectQueryFieldsByEntityIndex,
];
constructor(
queryBuilder: UpdateQueryBuilder<T>,
objectRecordsPermissions: ObjectRecordsPermissions,
@ -47,13 +57,16 @@ export class WorkspaceUpdateQueryBuilder<
this.shouldBypassPermissionChecks = shouldBypassPermissionChecks;
this.authContext = authContext;
this.featureFlagMap = featureFlagMap;
this.relationNestedQueries = new RelationNestedQueries(
this.internalContext,
);
}
override clone(): this {
const clonedQueryBuilder = super.clone();
return new WorkspaceUpdateQueryBuilder(
clonedQueryBuilder,
clonedQueryBuilder as UpdateQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
@ -94,6 +107,26 @@ export class WorkspaceUpdateQueryBuilder<
const before = await eventSelectQueryBuilder.getMany();
const nestedRelationQueryBuilder = new WorkspaceSelectQueryBuilder(
this as unknown as WorkspaceSelectQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
this.authContext,
);
const updatedValues =
await this.relationNestedQueries.processRelationNestedQueries({
entities: this.expressionMap.valuesSet as
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
relationNestedConfig: this.relationNestedConfig,
queryBuilder: nestedRelationQueryBuilder,
});
this.expressionMap.valuesSet =
updatedValues.length === 1 ? updatedValues[0] : updatedValues;
const formattedBefore = formatResult<T[]>(
before,
objectMetadata,
@ -131,7 +164,21 @@ export class WorkspaceUpdateQueryBuilder<
};
}
override set(_values: QueryDeepPartialEntity<T>): this {
override set(
_values:
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
): this;
override set(_values: QueryDeepPartialEntity<T>): this;
override set(
_values:
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[]
| QueryDeepPartialEntity<T>
| QueryDeepPartialEntity<T>[],
): this {
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
@ -139,9 +186,19 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext,
);
const extendedValues = _values as
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[];
this.relationNestedConfig =
this.relationNestedQueries.prepareNestedRelationQueries(
extendedValues,
mainAliasTarget,
);
const formattedUpdateSet = formatData(_values, objectMetadata);
return super.set(formattedUpdateSet);
return super.set(formattedUpdateSet as QueryDeepPartialEntity<T>);
}
override select(): WorkspaceSelectQueryBuilder<T> {

View File

@ -27,7 +27,7 @@ import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { QueryDeepPartialEntityWithRelationConnect } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
@ -536,8 +536,8 @@ export class WorkspaceRepository<
*/
override async insert(
entity:
| QueryDeepPartialEntityWithRelationConnect<T>
| QueryDeepPartialEntityWithRelationConnect<T>[],
| QueryDeepPartialEntityWithNestedRelationFields<T>
| QueryDeepPartialEntityWithNestedRelationFields<T>[],
entityManager?: WorkspaceEntityManager,
selectedColumns?: string[],
): Promise<InsertResult> {

View File

@ -160,9 +160,10 @@ describe('computeRelationConnectQueryConfigs', () => {
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
{},
);
expect(result).toEqual({});
expect(result).toEqual([]);
});
it('should throw an error if a connect field is not a relation field', () => {
@ -176,11 +177,18 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
name: { connect: { where: { name: { lastName: 'Doe' } } } },
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow('Connect is not allowed for name on person');
});
@ -195,11 +203,18 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': { connect: { where: { name: 'company1' } } },
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow(
"Missing required fields: at least one unique constraint have to be fully populated for 'company-related-to-1'.",
@ -222,11 +237,26 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': {
connect: {
where: {
domainName: { primaryLinkUrl: 'company1.com' },
id: '1',
address: 'company1 address',
},
},
},
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow(
"Field address is not a unique constraint field for 'company-related-to-1'.",
@ -255,11 +285,27 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': {
connect: {
where: {
domainName: { primaryLinkUrl: 'company1.com' },
},
},
},
},
'1': {
'company-related-to-1': { connect: { where: { id: '2' } } },
},
};
expect(() => {
computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
}).toThrow(
'Expected the same constraint fields to be used consistently across all operations for company-related-to-1.',
@ -298,14 +344,42 @@ describe('computeRelationConnectQueryConfigs', () => {
},
];
const relationConnectQueryFieldsByEntityIndex = {
'0': {
'company-related-to-1': {
connect: {
where: { domainName: { primaryLinkUrl: 'company.com' } },
},
},
'company-related-to-2': {
connect: {
where: { id: '1' },
},
},
},
'1': {
'company-related-to-1': {
connect: {
where: { domainName: { primaryLinkUrl: 'other-company.com' } },
},
},
'company-related-to-2': {
connect: {
where: { id: '2' },
},
},
},
};
const result = computeRelationConnectQueryConfigs(
peopleEntityInputs,
personMetadata,
objectMetadataMaps,
relationConnectQueryFieldsByEntityIndex,
);
expect(result).toEqual({
'company-related-to-1': {
expect(result).toEqual([
{
connectFieldName: 'company-related-to-1',
recordToConnectConditions: [
[['domainNamePrimaryLinkUrl', 'company.com']],
@ -326,7 +400,7 @@ describe('computeRelationConnectQueryConfigs', () => {
},
],
},
'company-related-to-2': {
{
connectFieldName: 'company-related-to-2',
recordToConnectConditions: [[['id', '1']], [['id', '2']]],
recordToConnectConditionByEntityIndex: {
@ -344,6 +418,6 @@ describe('computeRelationConnectQueryConfigs', () => {
},
],
},
});
]);
});
});

View File

@ -17,7 +17,7 @@ describe('createSqlWhereTupleInClause', () => {
const result = createSqlWhereTupleInClause(conditions, tableName);
expect(result.clause).toBe(
'(table_name.field1, table_name.field2) IN ((:value0_0, :value0_1), (:value1_0, :value1_1))',
'("table_name"."field1", "table_name"."field2") IN ((:value0_0, :value0_1), (:value1_0, :value1_1))',
);
expect(result.parameters).toEqual({
value0_0: 'value1',

View File

@ -16,6 +16,7 @@ import {
RelationConnectQueryConfig,
UniqueConstraintCondition,
} from 'src/engine/twenty-orm/entity-manager/types/relation-connect-query-config.type';
import { RelationConnectQueryFieldsByEntityIndex } from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
@ -28,19 +29,19 @@ export const computeRelationConnectQueryConfigs = (
entities: Record<string, unknown>[],
objectMetadata: ObjectMetadataItemWithFieldMaps,
objectMetadataMap: ObjectMetadataMaps,
relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex,
) => {
const allConnectQueryConfigs: Record<string, RelationConnectQueryConfig> = {};
for (const [entityIndex, entity] of entities.entries()) {
const connectFields = extractConnectFields(entity);
const nestedRelationConnectFields =
relationConnectQueryFieldsByEntityIndex[entityIndex];
if (connectFields.length === 0) {
continue;
}
for (const connectField of connectFields) {
const [connectFieldName, connectObject] = Object.entries(connectField)[0];
if (!isDefined(nestedRelationConnectFields)) continue;
for (const [connectFieldName, connectObject] of Object.entries(
nestedRelationConnectFields,
)) {
const {
recordToConnectCondition,
uniqueConstraintFields,
@ -78,7 +79,7 @@ export const computeRelationConnectQueryConfigs = (
}
}
return allConnectQueryConfigs;
return Object.values(allConnectQueryConfigs);
};
const updateConnectQueryConfigs = (
@ -177,63 +178,6 @@ const computeRecordToConnectCondition = (
};
};
const extractConnectFields = (
entity: Record<string, unknown>,
): { [connectFieldName: string]: ConnectObject }[] => {
const connectFields: { [entityKey: string]: ConnectObject }[] = [];
for (const [key, value] of Object.entries(entity)) {
if (hasRelationConnect(value)) {
connectFields.push({ [key]: value });
}
}
return connectFields;
};
const hasRelationConnect = (value: unknown): value is ConnectObject => {
if (!isDefined(value) || typeof value !== 'object') {
return false;
}
const obj = value as Record<string, unknown>;
if (!isDefined(obj.connect) || typeof obj.connect !== 'object') {
return false;
}
const connect = obj.connect as Record<string, unknown>;
if (!isDefined(connect.where) || typeof connect.where !== 'object') {
return false;
}
const where = connect.where as Record<string, unknown>;
const whereKeys = Object.keys(where);
if (whereKeys.length === 0) {
return false;
}
return whereKeys.every((key) => {
const whereValue = where[key];
if (typeof whereValue === 'string') {
return true;
}
if (whereValue && typeof whereValue === 'object') {
const subObj = whereValue as Record<string, unknown>;
return Object.values(subObj).every(
(subValue) => typeof subValue === 'string',
);
}
return false;
});
};
const checkUniqueConstraintFullyPopulated = (
objectMetadata: ObjectMetadataItemWithFieldMaps,
connectObject: ConnectObject,

View File

@ -5,7 +5,7 @@ export const createSqlWhereTupleInClause = (
const fieldNames = conditions[0].map(([field, _]) => field);
const tupleClause = fieldNames
.map((field) => `${tableName}.${field}`)
.map((field) => `"${tableName}"."${field}"`)
.join(', ');
const valuePlaceholders = conditions
.map((_, index) => {

View File

@ -0,0 +1,135 @@
import { isDefined } from 'class-validator';
import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants';
import {
ConnectObject,
DisconnectObject,
} from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-relation-connect.type';
import {
RelationConnectQueryFieldsByEntityIndex,
RelationDisconnectQueryFieldsByEntityIndex,
} from 'src/engine/twenty-orm/entity-manager/types/relation-nested-query-fields-by-entity-index.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
const hasRelationConnect = (value: unknown): value is ConnectObject => {
if (!isDefined(value) || typeof value !== 'object') {
return false;
}
const obj = value as Record<string, unknown>;
if (
!isDefined(obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT]) ||
typeof obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT] !== 'object'
) {
return false;
}
const connect = obj[RELATION_NESTED_QUERY_KEYWORDS.CONNECT] as Record<
string,
unknown
>;
if (
!isDefined(connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE]) ||
typeof connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE] !== 'object'
) {
return false;
}
const where = connect[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE] as Record<
string,
unknown
>;
const whereKeys = Object.keys(where);
if (whereKeys.length === 0) {
return false;
}
return whereKeys.every((key) => {
const whereValue = where[key];
if (typeof whereValue === 'string') {
return true;
}
if (whereValue && typeof whereValue === 'object') {
const subObj = whereValue as Record<string, unknown>;
return Object.values(subObj).every(
(subValue) => typeof subValue === 'string',
);
}
return false;
});
};
const hasRelationDisconnect = (value: unknown): value is DisconnectObject => {
if (!isDefined(value) || typeof value !== 'object') return false;
const obj = value as Record<string, unknown>;
if (
!isDefined(obj[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT]) ||
typeof obj[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT] !== 'boolean'
)
return false;
return true;
};
export const extractNestedRelationFieldsByEntityIndex = (
entities: Record<string, unknown>[],
): {
relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex;
relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex;
} => {
const relationConnectQueryFieldsByEntityIndex: RelationConnectQueryFieldsByEntityIndex =
{};
const relationDisconnectQueryFieldsByEntityIndex: RelationDisconnectQueryFieldsByEntityIndex =
{};
for (const [entityIndex, entity] of Object.entries(entities)) {
for (const [key, value] of Object.entries(entity)) {
const hasConnect = hasRelationConnect(value);
const hasDisconnect = hasRelationDisconnect(value);
if (hasConnect && hasDisconnect) {
throw new TwentyORMException(
`Cannot have both connect and disconnect for the same field on ${entity.key}.`,
TwentyORMExceptionCode.CONNECT_NOT_ALLOWED,
);
}
const relationConnectQueryFields =
relationConnectQueryFieldsByEntityIndex?.[entityIndex] || {};
if (hasConnect) {
relationConnectQueryFieldsByEntityIndex[entityIndex] = {
...relationConnectQueryFields,
[key]: value,
};
}
const relationDisconnectQueryFields =
relationDisconnectQueryFieldsByEntityIndex?.[entityIndex] || {};
if (hasDisconnect) {
relationDisconnectQueryFieldsByEntityIndex[entityIndex] = {
...relationDisconnectQueryFields,
[key]: value,
};
}
}
}
return {
relationConnectQueryFieldsByEntityIndex,
relationDisconnectQueryFieldsByEntityIndex,
};
};