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
@@ -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;
});
}
}