Added relations to SSE events (#17683)

Fixes https://github.com/twentyhq/core-team-issues/issues/2192

This PR implements what is necessary to re-create the query that we
build on the frontend to obtain the returned object record from a
mutation, but on the backend, which was only partially implemented for
REST API.

Usually we want to have relations with only their id and label
identifier field to have lighter payloads.

In the event we only had depth 0 fields, with this PR we have all events
with depth 1 relations.

We have depth 2 for many-to-many cases, like updateOne or updateMany
result :
- Junction tables
- Activity target tables
This commit is contained in:
Lucas Bordeau
2026-02-17 14:57:26 +01:00
committed by GitHub
parent 20977428a1
commit 8938dd637f
33 changed files with 2041 additions and 557 deletions
@@ -0,0 +1,430 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { type FindOptionsRelations, type ObjectLiteral } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util';
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import {
buildFieldMapsFromFlatObjectMetadata,
type FieldMapsForObject,
} from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@Injectable()
export class ProcessNestedRelationsV2Helper {
constructor() {}
public async processNestedRelations<T extends ObjectRecord = ObjectRecord>({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues = {},
relations,
aggregate = {},
limit,
authContext,
workspaceDataSource,
rolePermissionConfig,
selectedFields,
}: {
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
parentObjectMetadataItem: FlatObjectMetadata;
parentObjectRecords: T[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parentObjectRecordsAggregatedValues?: Record<string, any>;
relations: Record<string, FindOptionsRelations<ObjectLiteral>>;
aggregate?: Record<string, AggregationField>;
limit: number;
authContext: AuthContext;
workspaceDataSource: GlobalWorkspaceDataSource;
rolePermissionConfig?: RolePermissionConfig;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedFields: Record<string, any>;
}): Promise<void> {
const processRelationTasks = Object.entries(relations).map(
([sourceFieldName, nestedRelations]) =>
this.processRelation({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues,
sourceFieldName,
nestedRelations,
aggregate,
limit,
authContext,
workspaceDataSource,
rolePermissionConfig,
selectedFields:
selectedFields[sourceFieldName] instanceof Object
? selectedFields[sourceFieldName]
: undefined,
}),
);
await Promise.all(processRelationTasks);
}
private async processRelation<T extends ObjectRecord = ObjectRecord>({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues,
sourceFieldName,
nestedRelations,
aggregate,
limit,
authContext,
workspaceDataSource,
rolePermissionConfig,
selectedFields,
}: {
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
parentObjectMetadataItem: FlatObjectMetadata;
parentObjectRecords: T[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parentObjectRecordsAggregatedValues: Record<string, any>;
sourceFieldName: string;
nestedRelations: FindOptionsRelations<ObjectLiteral>;
aggregate: Record<string, AggregationField>;
limit: number;
authContext: AuthContext;
workspaceDataSource: GlobalWorkspaceDataSource;
rolePermissionConfig?: RolePermissionConfig;
selectedFields: Record<string, unknown>;
}): Promise<void> {
const fieldMaps = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
parentObjectMetadataItem,
);
const sourceFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldMaps.fieldIdByName[sourceFieldName],
flatEntityMaps: flatFieldMetadataMaps,
});
if (!sourceFieldMetadata) {
return;
}
if (
!isFieldMetadataEntityOfType(
sourceFieldMetadata,
FieldMetadataType.RELATION,
) &&
!isFieldMetadataEntityOfType(
sourceFieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
) {
// TODO: Maybe we should throw an error here ?
return;
}
if (!sourceFieldMetadata.settings) {
throw new GraphqlQueryRunnerException(
`Relation settings not found for field ${sourceFieldName}`,
GraphqlQueryRunnerExceptionCode.RELATION_SETTINGS_NOT_FOUND,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const relationType = sourceFieldMetadata.settings?.relationType;
const { targetRelationName, targetObjectMetadata, targetRelation } =
this.getTargetObjectMetadata({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem,
sourceFieldName,
fieldMaps,
});
const targetObjectRepository = workspaceDataSource.getRepository(
targetObjectMetadata.nameSingular,
rolePermissionConfig,
);
const targetObjectNameSingular = targetObjectMetadata.nameSingular;
let targetObjectQueryBuilder = targetObjectRepository.createQueryBuilder(
targetObjectNameSingular,
);
const columnsToSelect = buildColumnsToSelect({
select: selectedFields,
relations: nestedRelations,
flatObjectMetadata: targetObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
targetObjectQueryBuilder = targetObjectQueryBuilder.setFindOptions({
select: columnsToSelect,
});
const relationIds = this.getUniqueIds({
records: parentObjectRecords,
idField:
relationType === RelationType.ONE_TO_MANY
? 'id'
: (sourceFieldMetadata.settings.joinColumnName ??
`${sourceFieldName}Id`),
});
const fieldMetadataTargetRelationColumnName =
targetRelation &&
isFieldMetadataEntityOfType(
targetRelation,
FieldMetadataType.MORPH_RELATION,
)
? `${targetRelation.settings?.joinColumnName}`
: `${targetRelationName}Id`;
const { relationResults, relationAggregatedFieldsResult } =
await this.findRelations({
referenceQueryBuilder: targetObjectQueryBuilder,
column:
relationType === RelationType.ONE_TO_MANY
? `"${fieldMetadataTargetRelationColumnName}"`
: 'id',
ids: relationIds,
limit: limit * parentObjectRecords.length,
aggregate,
sourceFieldName,
targetObjectNameSingular,
});
this.assignRelationResults({
parentRecords: parentObjectRecords,
parentObjectRecordsAggregatedValues,
relationResults,
relationAggregatedFieldsResult,
sourceFieldName,
joinField:
relationType === RelationType.ONE_TO_MANY
? `${fieldMetadataTargetRelationColumnName}`
: 'id',
relationType,
});
if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem: targetObjectMetadata,
parentObjectRecords: relationResults as ObjectRecord[],
parentObjectRecordsAggregatedValues: relationAggregatedFieldsResult,
relations: nestedRelations as Record<
string,
FindOptionsRelations<ObjectLiteral>
>,
aggregate,
limit,
authContext,
workspaceDataSource,
rolePermissionConfig,
selectedFields,
});
}
}
private getTargetObjectMetadata({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem,
sourceFieldName,
fieldMaps,
}: {
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
parentObjectMetadataItem: FlatObjectMetadata;
sourceFieldName: string;
fieldMaps: FieldMapsForObject;
}) {
const targetFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldMaps.fieldIdByName[sourceFieldName],
flatEntityMaps: flatFieldMetadataMaps,
});
if (!targetFieldMetadata) {
throw new GraphqlQueryRunnerException(
`Field ${sourceFieldName} not found on object ${parentObjectMetadataItem.nameSingular}`,
GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const targetObjectMetadata = getTargetObjectMetadataOrThrow(
targetFieldMetadata,
flatObjectMetadataMaps,
);
if (
!targetFieldMetadata.relationTargetObjectMetadataId ||
!targetFieldMetadata.relationTargetFieldMetadataId
) {
throw new GraphqlQueryRunnerException(
`Relation target object metadata id or field metadata id not found for field ${sourceFieldName}`,
GraphqlQueryRunnerExceptionCode.RELATION_TARGET_OBJECT_METADATA_NOT_FOUND,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const targetRelation = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: targetFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
const targetRelationName = targetRelation?.name;
return { targetRelationName, targetObjectMetadata, targetRelation };
}
private getUniqueIds({
records,
idField,
}: {
records: ObjectRecord[];
idField: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}): any[] {
return [...new Set(records.map((item) => item[idField]))];
}
private async findRelations({
referenceQueryBuilder,
column,
ids,
limit,
aggregate,
sourceFieldName,
targetObjectNameSingular,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
referenceQueryBuilder: WorkspaceSelectQueryBuilder<any>;
column: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ids: any[];
limit: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aggregate: Record<string, any>;
sourceFieldName: string;
targetObjectNameSingular: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}): Promise<{ relationResults: any[]; relationAggregatedFieldsResult: any }> {
if (ids.length === 0) {
return { relationResults: [], relationAggregatedFieldsResult: {} };
}
const aggregateForRelation = aggregate[sourceFieldName];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let relationAggregatedFieldsResult: Record<string, any> = {};
if (aggregateForRelation) {
const aggregateQueryBuilder = referenceQueryBuilder.clone();
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: aggregateForRelation,
queryBuilder: aggregateQueryBuilder,
objectMetadataNameSingular: targetObjectNameSingular,
});
const aggregatedFieldsValues = await aggregateQueryBuilder
.addSelect(column)
.where(`${column} IN (:...ids)`, {
ids,
})
.groupBy(column)
.getRawMany();
relationAggregatedFieldsResult = aggregatedFieldsValues.reduce(
(acc, item) => {
const columnWithoutQuotes = column.replace(/["']/g, '');
const key = item[columnWithoutQuotes];
const { [column]: _, ...itemWithoutColumn } = item;
acc[key] = itemWithoutColumn;
return acc;
},
{},
);
}
const queryBuilderOptions = referenceQueryBuilder.getFindOptions();
const columnWithoutQuotes = column.replace(/["']/g, '');
const result = await referenceQueryBuilder
.setFindOptions({
...queryBuilderOptions,
select: { ...queryBuilderOptions.select, [columnWithoutQuotes]: true },
})
.where(`${column} IN (:...ids)`, {
ids,
})
.take(limit)
.getMany();
return { relationResults: result, relationAggregatedFieldsResult };
}
private assignRelationResults({
parentRecords,
parentObjectRecordsAggregatedValues,
relationResults,
relationAggregatedFieldsResult,
sourceFieldName,
joinField,
relationType,
}: {
parentRecords: ObjectRecord[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parentObjectRecordsAggregatedValues: Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
relationResults: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
relationAggregatedFieldsResult: Record<string, any>;
sourceFieldName: string;
joinField: string;
relationType: RelationType;
}): void {
parentRecords.forEach((item) => {
if (relationType === RelationType.ONE_TO_MANY) {
item[sourceFieldName] = relationResults.filter(
(rel) => rel[joinField] === item.id,
);
} else {
item[sourceFieldName] =
relationResults.find(
(rel) => rel.id === item[`${sourceFieldName}Id`],
) ?? null;
}
});
parentObjectRecordsAggregatedValues[sourceFieldName] =
relationAggregatedFieldsResult;
}
}
@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { type ObjectRecord } from 'twenty-shared/types';
import { type FindOptionsRelations, type ObjectLiteral } from 'typeorm';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource';
import { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class ProcessNestedRelationsHelper {
constructor(
private readonly processNestedRelationsV2Helper: ProcessNestedRelationsV2Helper,
) {}
public async processNestedRelations<T extends ObjectRecord = ObjectRecord>({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues = {},
relations,
aggregate = {},
limit,
authContext,
workspaceDataSource,
rolePermissionConfig,
selectedFields,
}: {
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
parentObjectMetadataItem: FlatObjectMetadata;
parentObjectRecords: T[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parentObjectRecordsAggregatedValues?: Record<string, any>;
relations: Record<string, FindOptionsRelations<ObjectLiteral>>;
aggregate?: Record<string, AggregationField>;
limit: number;
authContext: AuthContext;
workspaceDataSource: GlobalWorkspaceDataSource;
rolePermissionConfig?: RolePermissionConfig;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectedFields: Record<string, any>;
}): Promise<void> {
return this.processNestedRelationsV2Helper.processNestedRelations({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues,
relations,
aggregate,
limit,
authContext,
workspaceDataSource,
rolePermissionConfig,
selectedFields,
});
}
}