feat: new relation in graphql-query-runner (#9883)

Fix https://github.com/twentyhq/core-team-issues/issues/300

Within GraphQLQueryRunner the new relation format will be used when the
feature flag `IsNewRelationEnabled` is set to true.
This commit is contained in:
Jérémy M
2025-01-29 17:04:39 +01:00
committed by GitHub
parent 29745c6756
commit 07197d1e6d
31 changed files with 729 additions and 138 deletions

View File

@ -5,6 +5,7 @@ import {
ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { CONNECTION_MAX_DEPTH } from 'src/engine/api/graphql/graphql-query-runner/constants/connection-max-depth.constant';
@ -14,7 +15,9 @@ import {
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { getRelationObjectMetadata } from 'src/engine/api/graphql/graphql-query-runner/utils/get-relation-object-metadata.util';
import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util';
import { AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@ -26,9 +29,14 @@ import { isPlainObject } from 'src/utils/is-plain-object';
export class ObjectRecordsToGraphqlConnectionHelper {
private objectMetadataMaps: ObjectMetadataMaps;
private featureFlagsMap: FeatureFlagMap;
constructor(objectMetadataMaps: ObjectMetadataMaps) {
constructor(
objectMetadataMaps: ObjectMetadataMaps,
featureFlagsMap: FeatureFlagMap,
) {
this.objectMetadataMaps = objectMetadataMaps;
this.featureFlagsMap = featureFlagsMap;
}
public createConnection<T extends ObjectRecord = ObjectRecord>({
@ -146,6 +154,9 @@ export class ObjectRecordsToGraphqlConnectionHelper {
);
}
const isNewRelationEnabled =
this.featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled];
const objectMetadata = getObjectMetadataMapItemByNameSingular(
this.objectMetadataMaps,
objectName,
@ -170,6 +181,13 @@ export class ObjectRecordsToGraphqlConnectionHelper {
if (isRelationFieldMetadataType(fieldMetadata.type)) {
if (Array.isArray(value)) {
const targetObjectMetadata = isNewRelationEnabled
? getTargetObjectMetadataOrThrow(
fieldMetadata,
this.objectMetadataMaps,
)
: getRelationObjectMetadata(fieldMetadata, this.objectMetadataMaps);
processedObjectRecord[key] = this.createConnection({
objectRecords: value,
parentObjectRecord: objectRecord,
@ -177,10 +195,7 @@ export class ObjectRecordsToGraphqlConnectionHelper {
objectRecordsAggregatedValues[fieldMetadata.name],
selectedAggregatedFields:
selectedAggregatedFields[fieldMetadata.name],
objectName: getRelationObjectMetadata(
fieldMetadata,
this.objectMetadataMaps,
).nameSingular,
objectName: targetObjectMetadata.nameSingular,
take,
totalCount:
objectRecordsAggregatedValues[fieldMetadata.name]?.totalCount ??
@ -191,16 +206,20 @@ export class ObjectRecordsToGraphqlConnectionHelper {
depth: depth + 1,
});
} else if (isPlainObject(value)) {
const targetObjectMetadata = isNewRelationEnabled
? getTargetObjectMetadataOrThrow(
fieldMetadata,
this.objectMetadataMaps,
)
: getRelationObjectMetadata(fieldMetadata, this.objectMetadataMaps);
processedObjectRecord[key] = this.processRecord({
objectRecord: value,
objectRecordsAggregatedValues:
objectRecordsAggregatedValues[fieldMetadata.name],
selectedAggregatedFields:
selectedAggregatedFields[fieldMetadata.name],
objectName: getRelationObjectMetadata(
fieldMetadata,
this.objectMetadataMaps,
).nameSingular,
objectName: targetObjectMetadata.nameSingular,
take,
totalCount,
order,

View File

@ -1,3 +1,5 @@
import { Injectable } from '@nestjs/common';
import { SelectQueryBuilder } from 'typeorm';
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
@ -5,6 +7,7 @@ import { AggregationField } from 'src/engine/api/graphql/workspace-schema-builde
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
import { isDefined } from 'src/utils/is-defined';
@Injectable()
export class ProcessAggregateHelper {
public addSelectedAggregatedFieldsQueriesToQueryBuilder = ({
selectedAggregatedFields,

View File

@ -0,0 +1,347 @@
import { Injectable } from '@nestjs/common';
import {
DataSource,
FindOptionsRelations,
ObjectLiteral,
SelectQueryBuilder,
} from 'typeorm';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
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 { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util';
import { 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 { 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 { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { isRelationFieldMetadata } from 'src/engine/utils/is-relation-field-metadata.util';
@Injectable()
export class ProcessNestedRelationsV2Helper {
constructor(
private readonly processAggregateHelper: ProcessAggregateHelper,
) {}
public async processNestedRelations<T extends ObjectRecord = ObjectRecord>({
objectMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues = {},
relations,
aggregate = {},
limit,
authContext,
dataSource,
}: {
objectMetadataMaps: ObjectMetadataMaps;
parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps;
parentObjectRecords: T[];
parentObjectRecordsAggregatedValues?: Record<string, any>;
relations: Record<string, FindOptionsRelations<ObjectLiteral>>;
aggregate?: Record<string, AggregationField>;
limit: number;
authContext: AuthContext;
dataSource: DataSource;
}): Promise<void> {
const processRelationTasks = Object.entries(relations).map(
([sourceFieldName, nestedRelations]) =>
this.processRelation({
objectMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues,
sourceFieldName,
nestedRelations,
aggregate,
limit,
authContext,
dataSource,
}),
);
await Promise.all(processRelationTasks);
}
private async processRelation<T extends ObjectRecord = ObjectRecord>({
objectMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues,
sourceFieldName,
nestedRelations,
aggregate,
limit,
authContext,
dataSource,
}: {
objectMetadataMaps: ObjectMetadataMaps;
parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps;
parentObjectRecords: T[];
parentObjectRecordsAggregatedValues: Record<string, any>;
sourceFieldName: string;
nestedRelations: FindOptionsRelations<ObjectLiteral>;
aggregate: Record<string, AggregationField>;
limit: number;
authContext: AuthContext;
dataSource: DataSource;
}): Promise<void> {
const sourceFieldMetadata =
parentObjectMetadataItem.fieldsByName[sourceFieldName];
if (!isRelationFieldMetadata(sourceFieldMetadata)) {
// 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,
);
}
const relationType = sourceFieldMetadata.settings?.relationType;
const { targetRelationName, targetObjectMetadata } =
this.getTargetObjectMetadata({
objectMetadataMaps,
parentObjectMetadataItem,
sourceFieldName,
});
const targetObjectRepository = dataSource.getRepository(
targetObjectMetadata.nameSingular,
);
const targetObjectQueryBuilder = targetObjectRepository.createQueryBuilder(
targetObjectMetadata.nameSingular,
);
const relationIds = this.getUniqueIds({
records: parentObjectRecords,
idField:
relationType === RelationType.ONE_TO_MANY
? 'id'
: `${sourceFieldName}Id`,
});
const { relationResults, relationAggregatedFieldsResult } =
await this.findRelations({
referenceQueryBuilder: targetObjectQueryBuilder,
column:
relationType === RelationType.ONE_TO_MANY
? `"${targetRelationName}Id"`
: 'id',
ids: relationIds,
limit,
objectMetadataMaps,
targetObjectMetadata,
aggregate,
sourceFieldName,
});
this.assignRelationResults({
parentRecords: parentObjectRecords,
parentObjectRecordsAggregatedValues,
relationResults,
relationAggregatedFieldsResult,
sourceFieldName,
joinField:
relationType === RelationType.ONE_TO_MANY
? `${targetRelationName}Id`
: 'id',
relationType,
});
const targetObjectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
targetObjectMetadata.nameSingular,
);
if (!targetObjectMetadataItemWithFieldsMaps) {
throw new GraphqlQueryRunnerException(
`Object ${targetObjectMetadata.nameSingular} not found`,
GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: targetObjectMetadataItemWithFieldsMaps,
parentObjectRecords: relationResults as ObjectRecord[],
parentObjectRecordsAggregatedValues: relationAggregatedFieldsResult,
relations: nestedRelations as Record<
string,
FindOptionsRelations<ObjectLiteral>
>,
aggregate,
limit,
authContext,
dataSource,
});
}
}
private getTargetObjectMetadata({
objectMetadataMaps,
parentObjectMetadataItem,
sourceFieldName,
}: {
objectMetadataMaps: ObjectMetadataMaps;
parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps;
sourceFieldName: string;
}) {
const targetFieldMetadata =
parentObjectMetadataItem.fieldsByName[sourceFieldName];
const targetObjectMetadata = getTargetObjectMetadataOrThrow(
targetFieldMetadata,
objectMetadataMaps,
);
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,
);
}
const targetRelationName =
objectMetadataMaps.byId[
targetFieldMetadata.relationTargetObjectMetadataId
]?.fieldsById[targetFieldMetadata.relationTargetFieldMetadataId]?.name;
return { targetRelationName, targetObjectMetadata };
}
private getUniqueIds({
records,
idField,
}: {
records: ObjectRecord[];
idField: string;
}): any[] {
return [...new Set(records.map((item) => item[idField]))];
}
private async findRelations({
referenceQueryBuilder,
column,
ids,
limit,
objectMetadataMaps,
targetObjectMetadata,
aggregate,
sourceFieldName,
}: {
referenceQueryBuilder: SelectQueryBuilder<any>;
column: string;
ids: any[];
limit: number;
objectMetadataMaps: ObjectMetadataMaps;
targetObjectMetadata: ObjectMetadataItemWithFieldMaps;
aggregate: Record<string, any>;
sourceFieldName: string;
}): Promise<{ relationResults: any[]; relationAggregatedFieldsResult: any }> {
if (ids.length === 0) {
return { relationResults: [], relationAggregatedFieldsResult: {} };
}
const aggregateForRelation = aggregate[sourceFieldName];
let relationAggregatedFieldsResult: Record<string, any> = {};
if (aggregateForRelation) {
const aggregateQueryBuilder = referenceQueryBuilder.clone();
this.processAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder(
{
selectedAggregatedFields: aggregateForRelation,
queryBuilder: aggregateQueryBuilder,
},
);
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 result = await referenceQueryBuilder
.where(`${column} IN (:...ids)`, {
ids,
})
.take(limit)
.getMany();
const relationResults = formatResult<ObjectRecord[]>(
result,
targetObjectMetadata,
objectMetadataMaps,
);
return { relationResults, relationAggregatedFieldsResult };
}
private assignRelationResults({
parentRecords,
parentObjectRecordsAggregatedValues,
relationResults,
relationAggregatedFieldsResult,
sourceFieldName,
joinField,
relationType,
}: {
parentRecords: ObjectRecord[];
parentObjectRecordsAggregatedValues: Record<string, any>;
relationResults: 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 {
if (relationResults.length === 0) {
item[`${sourceFieldName}Id`] = null;
}
item[sourceFieldName] =
relationResults.find(
(rel) => rel.id === item[`${sourceFieldName}Id`],
) ?? null;
}
});
parentObjectRecordsAggregatedValues[sourceFieldName] =
relationAggregatedFieldsResult;
}
}

View File

@ -1,3 +1,5 @@
import { Injectable } from '@nestjs/common';
import {
DataSource,
FindOptionsRelations,
@ -12,23 +14,28 @@ import {
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 { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper';
import {
getRelationMetadata,
getRelationObjectMetadata,
} from 'src/engine/api/graphql/graphql-query-runner/utils/get-relation-object-metadata.util';
import { 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 { 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 { 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 { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { deduceRelationDirection } from 'src/engine/utils/deduce-relation-direction.util';
@Injectable()
export class ProcessNestedRelationsHelper {
private processAggregateHelper: ProcessAggregateHelper;
constructor() {
this.processAggregateHelper = new ProcessAggregateHelper();
}
constructor(
private readonly processNestedRelationsV2Helper: ProcessNestedRelationsV2Helper,
private readonly processAggregateHelper: ProcessAggregateHelper,
private readonly featureFlagService: FeatureFlagService,
) {}
public async processNestedRelations<T extends ObjectRecord = ObjectRecord>({
objectMetadataMaps,
@ -48,9 +55,28 @@ export class ProcessNestedRelationsHelper {
relations: Record<string, FindOptionsRelations<ObjectLiteral>>;
aggregate?: Record<string, AggregationField>;
limit: number;
authContext: any;
authContext: AuthContext;
dataSource: DataSource;
}): Promise<void> {
const isNewRelationEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsNewRelationEnabled,
authContext.workspace.id,
);
if (isNewRelationEnabled) {
return this.processNestedRelationsV2Helper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem,
parentObjectRecords,
parentObjectRecordsAggregatedValues,
relations,
aggregate,
limit,
authContext,
dataSource,
});
}
const processRelationTasks = Object.entries(relations).map(
([relationName, nestedRelations]) =>
this.processRelation({