Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol

const existingRec = existingRecords.find(
(existingRecord) =>
isDefined(existingRecord[field.column]) &&
existingRecord[field.column] === requestFieldValue,
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Injectable } from '@nestjs/common';

import {
GraphQLBoolean,
GraphQLInputFieldConfig,
GraphQLInputObjectType,
GraphQLInputType,
GraphQLString,
} from 'graphql';
import { RELATION_NESTED_QUERY_KEYWORDS } from 'twenty-shared/constants';

import {
InputTypeDefinition,
Expand Down Expand Up @@ -36,6 +38,11 @@ export class RelationConnectInputTypeDefinitionFactory {
kind: InputTypeDefinitionKind.Create,
type: fields,
},
{
target,
kind: InputTypeDefinitionKind.Update,
type: fields,
},
];
}

Expand All @@ -45,13 +52,17 @@ export class RelationConnectInputTypeDefinitionFactory {
return new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}RelationInput`,
fields: () => ({
connect: {
[RELATION_NESTED_QUERY_KEYWORDS.CONNECT]: {
type: new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}ConnectInput`,
fields: this.generateRelationWhereInputType(objectMetadata),
}),
description: `Connect to a ${objectMetadata.nameSingular} record`,
},
[RELATION_NESTED_QUERY_KEYWORDS.DISCONNECT]: {
type: GraphQLBoolean,
description: `Disconnect from a ${objectMetadata.nameSingular} record`,
},
}),
});
}
Expand Down Expand Up @@ -127,7 +138,7 @@ export class RelationConnectInputTypeDefinitionFactory {
});

return {
where: {
[RELATION_NESTED_QUERY_KEYWORDS.CONNECT_WHERE]: {
type: new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}WhereUniqueInput`,
fields: () => fields,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ const generateRelationField = <
};

if (
[InputTypeDefinitionKind.Create].includes(
[InputTypeDefinitionKind.Create, InputTypeDefinitionKind.Update].includes(
kind as InputTypeDefinitionKind,
) &&
isDefined(fieldMetadata.relationTargetObjectMetadataId)
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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];
};
Original file line number Diff line number Diff line change
@@ -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;
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -193,7 +177,7 @@ export class WorkspaceEntityManager extends EntityManager {
)
.insert()
.into(target)
.values(connectedEntities)
.values(entity)
.returning(selectedColumns)
.execute();
}
Expand Down Expand Up @@ -1484,118 +1468,4 @@ export class WorkspaceEntityManager extends EntityManager {
PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED,
);
}

private async processRelationConnect<Entity extends ObjectLiteral>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to implement the same logic for methods that don't rely on query builders?
save, recover, delete, destroy?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do it for the .save. Regarding the others, it does not seem useful as they are not create/update operations

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll push it later, tomorrow

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