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:
-161
@@ -1,161 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { getAllSelectableFields } from 'src/engine/api/rest/core/rest-to-common-args-handlers/utils/get-all-selectable-fields.util';
|
||||
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
|
||||
import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
@Injectable()
|
||||
export class CommonSelectedFieldsHandler {
|
||||
computeFromDepth = ({
|
||||
objectsPermissions,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
depth,
|
||||
}: {
|
||||
objectsPermissions: ObjectsPermissions;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
depth: Depth | undefined;
|
||||
}): CommonSelectedFieldsResult => {
|
||||
const restrictedFields =
|
||||
objectsPermissions[flatObjectMetadata.id].restrictedFields;
|
||||
|
||||
const { relations, relationsSelectFields } =
|
||||
this.getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
});
|
||||
|
||||
const selectableFields = getAllSelectableFields({
|
||||
restrictedFields,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
select: {
|
||||
...selectableFields,
|
||||
...relationsSelectFields,
|
||||
},
|
||||
relations,
|
||||
aggregate: {},
|
||||
};
|
||||
};
|
||||
|
||||
private getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
}: {
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
objectsPermissions: ObjectsPermissions;
|
||||
depth: Depth | undefined;
|
||||
}) {
|
||||
if (!isDefined(depth) || depth === 0) {
|
||||
return {
|
||||
relations: {},
|
||||
relationsSelectFields: {},
|
||||
};
|
||||
}
|
||||
|
||||
let relations: { [key: string]: boolean | { [key: string]: boolean } } = {};
|
||||
|
||||
let relationsSelectFields: {
|
||||
[key: string]:
|
||||
| boolean
|
||||
| { [key: string]: boolean | { [key: string]: boolean } };
|
||||
} = {};
|
||||
|
||||
for (const fieldId of flatObjectMetadata.fieldIds) {
|
||||
const field = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: fieldId,
|
||||
});
|
||||
|
||||
if (!isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION))
|
||||
continue;
|
||||
|
||||
if (!field.relationTargetObjectMetadataId) continue;
|
||||
|
||||
const relationTargetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: field.relationTargetObjectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(relationTargetObjectMetadata)) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Object metadata relation target not found for relation creation payload`,
|
||||
CommonQueryRunnerExceptionCode.BAD_REQUEST,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
}
|
||||
const relationFieldSelectFields = getAllSelectableFields({
|
||||
restrictedFields:
|
||||
objectsPermissions[relationTargetObjectMetadata.id].restrictedFields,
|
||||
flatObjectMetadata: relationTargetObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (Object.keys(relationFieldSelectFields).length === 0) continue;
|
||||
|
||||
if (
|
||||
depth === MAX_DEPTH &&
|
||||
isDefined(field.relationTargetObjectMetadataId)
|
||||
) {
|
||||
const {
|
||||
relations: depth2Relations,
|
||||
relationsSelectFields: depth2RelationsSelectFields,
|
||||
} = this.getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata: relationTargetObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth: 1,
|
||||
}) as {
|
||||
relations: { [key: string]: boolean };
|
||||
relationsSelectFields: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
relations[field.name] = depth2Relations as {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
|
||||
relationsSelectFields[field.name] = {
|
||||
...relationFieldSelectFields,
|
||||
...depth2RelationsSelectFields,
|
||||
};
|
||||
} else {
|
||||
relations[field.name] = true;
|
||||
relationsSelectFields[field.name] = relationFieldSelectFields;
|
||||
}
|
||||
}
|
||||
|
||||
return { relations, relationsSelectFields };
|
||||
}
|
||||
}
|
||||
+430
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,7 @@ import { QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-ru
|
||||
|
||||
import { DataArgProcessor } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor';
|
||||
import { QueryRunnerArgsFactory } from 'src/engine/api/common/common-args-processors/query-runner-args.factory';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
CommonQueryRunnerExceptionCode,
|
||||
@@ -25,7 +26,6 @@ import { CommonQueryResult } from 'src/engine/api/common/types/common-query-resu
|
||||
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements';
|
||||
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
|
||||
import { WorkspacePreQueryHookPayload } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
|
||||
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getAllSelectableFields } from 'src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util';
|
||||
import { getIsFlatFieldAJoinColumn } from 'src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-join-column.util';
|
||||
import { getIsFlatFieldAJunctionRelationField } from 'src/engine/api/common/common-select-fields/utils/get-is-flat-field-a-junction-relation-field';
|
||||
import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
|
||||
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
|
||||
import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
|
||||
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
type SelectFields = {
|
||||
[key: string]: boolean | SelectFields;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CommonSelectFieldsHelper {
|
||||
computeFromDepth = ({
|
||||
objectsPermissions,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
depth,
|
||||
onlyUseLabelIdentifierFieldsInRelations = false,
|
||||
recurseIntoJunctionTableRelations = false,
|
||||
}: {
|
||||
objectsPermissions: ObjectsPermissions;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
depth: Depth | undefined;
|
||||
onlyUseLabelIdentifierFieldsInRelations?: boolean;
|
||||
recurseIntoJunctionTableRelations?: boolean;
|
||||
}): CommonSelectedFields => {
|
||||
const restrictedFields =
|
||||
objectsPermissions[flatObjectMetadata.id].restrictedFields;
|
||||
|
||||
const relationsSelectFields = this.getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
onlyUseLabelIdentifierFieldsInRelations,
|
||||
recurseIntoJunctionTableRelations,
|
||||
});
|
||||
|
||||
const selectableFields = getAllSelectableFields({
|
||||
restrictedFields,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
...selectableFields,
|
||||
...relationsSelectFields,
|
||||
};
|
||||
};
|
||||
|
||||
private getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth,
|
||||
onlyUseLabelIdentifierFieldsInRelations = false,
|
||||
currentDepthLevelIsAJunctionTable = false,
|
||||
recurseIntoJunctionTableRelations = false,
|
||||
}: {
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
objectsPermissions: ObjectsPermissions;
|
||||
depth: Depth | undefined;
|
||||
onlyUseLabelIdentifierFieldsInRelations?: boolean;
|
||||
currentDepthLevelIsAJunctionTable?: boolean;
|
||||
recurseIntoJunctionTableRelations?: boolean;
|
||||
}) {
|
||||
if (!isDefined(depth) || depth === 0) return {};
|
||||
|
||||
let relationsSelectFields: SelectFields = {};
|
||||
|
||||
for (const fieldId of flatObjectMetadata.fieldIds) {
|
||||
const flatField = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: fieldId,
|
||||
});
|
||||
|
||||
if (!isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentDepthLevelIsAJunctionTable) {
|
||||
const fieldIsJunctionRelation = getIsFlatFieldAJunctionRelationField({
|
||||
flatField,
|
||||
});
|
||||
|
||||
if (!fieldIsJunctionRelation) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const relationTargetObjectMetadata =
|
||||
findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
flatEntityId: flatField.relationTargetObjectMetadataId,
|
||||
});
|
||||
|
||||
const relationFieldSelectFields = getAllSelectableFields({
|
||||
restrictedFields:
|
||||
objectsPermissions[relationTargetObjectMetadata.id].restrictedFields,
|
||||
flatObjectMetadata: relationTargetObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations,
|
||||
});
|
||||
|
||||
if (Object.keys(relationFieldSelectFields).length === 0) continue;
|
||||
|
||||
const flatFieldIsJoinColumn = getIsFlatFieldAJoinColumn({ flatField });
|
||||
|
||||
const isFirstDepthLevel =
|
||||
depth === MAX_DEPTH &&
|
||||
isDefined(flatField.relationTargetObjectMetadataId);
|
||||
|
||||
const shouldRecurseIntoRelation =
|
||||
isFirstDepthLevel ||
|
||||
(flatFieldIsJoinColumn && recurseIntoJunctionTableRelations);
|
||||
|
||||
const nextLevelIsAJunctionTable = flatFieldIsJoinColumn;
|
||||
|
||||
if (shouldRecurseIntoRelation) {
|
||||
const nestedRelationFieldSelectFields =
|
||||
this.getRelationsAndRelationsSelectFields({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadata: relationTargetObjectMetadata,
|
||||
objectsPermissions,
|
||||
depth: 1,
|
||||
onlyUseLabelIdentifierFieldsInRelations,
|
||||
currentDepthLevelIsAJunctionTable: nextLevelIsAJunctionTable,
|
||||
recurseIntoJunctionTableRelations,
|
||||
});
|
||||
|
||||
relationsSelectFields[flatField.name] = {
|
||||
...relationFieldSelectFields,
|
||||
...nestedRelationFieldSelectFields,
|
||||
};
|
||||
} else {
|
||||
relationsSelectFields[flatField.name] = relationFieldSelectFields;
|
||||
}
|
||||
}
|
||||
|
||||
return relationsSelectFields;
|
||||
}
|
||||
}
|
||||
+645
@@ -0,0 +1,645 @@
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
|
||||
import { getAllSelectableFields } from 'src/engine/api/common/common-select-fields/utils/get-all-selectable-fields.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
describe('getAllSelectableFields', () => {
|
||||
const createMockField = (
|
||||
overrides: Partial<FlatFieldMetadata> & {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldMetadataType;
|
||||
},
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
objectMetadataId: 'object-id',
|
||||
workspaceId: 'workspace-id',
|
||||
isNullable: true,
|
||||
isLabelSyncedWithName: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
universalIdentifier: overrides.id,
|
||||
viewFieldIds: [],
|
||||
viewFilterIds: [],
|
||||
kanbanAggregateOperationViewIds: [],
|
||||
calendarViewIds: [],
|
||||
applicationId: null,
|
||||
label: overrides.name,
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
const buildFlatFieldMetadataMaps = (
|
||||
fields: FlatFieldMetadata[],
|
||||
): FlatEntityMaps<FlatFieldMetadata> => ({
|
||||
byUniversalIdentifier: fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.universalIdentifier] = field;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FlatFieldMetadata>,
|
||||
),
|
||||
universalIdentifierById: fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.id] = field.universalIdentifier;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
universalIdentifiersByApplicationId: {},
|
||||
});
|
||||
|
||||
const buildFlatObjectMetadata = (fieldIds: string[]): FlatObjectMetadata =>
|
||||
({
|
||||
id: 'object-id',
|
||||
workspaceId: 'workspace-id',
|
||||
nameSingular: 'testObject',
|
||||
namePlural: 'testObjects',
|
||||
labelSingular: 'Test Object',
|
||||
labelPlural: 'Test Objects',
|
||||
isCustom: false,
|
||||
isRemote: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
universalIdentifier: 'object-id',
|
||||
fieldIds: fieldIds,
|
||||
indexMetadataIds: [],
|
||||
viewIds: [],
|
||||
applicationId: null,
|
||||
}) as unknown as FlatObjectMetadata;
|
||||
|
||||
it('should return all fields as selectable when no restrictions', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
email: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return restricted fields', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create nested objects for composite fields', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'fullName',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
const field3 = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'domainName',
|
||||
type: FieldMetadataType.LINKS,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
field3,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
fullName: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
domainName: {
|
||||
primaryLinkLabel: true,
|
||||
primaryLinkUrl: true,
|
||||
secondaryLinks: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should restrict all sub-fields when composite field is restricted', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'fullName',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([field1, field2]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata(['field-1', 'field-2']);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed regular and composite fields with restrictions', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field3 = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'fullName',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
const field4 = createMockField({
|
||||
id: 'field-4',
|
||||
name: 'address',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
});
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
field3,
|
||||
field4,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
'field-4',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
'field-4': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
fullName: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyUseLabelIdentifierFields flag', () => {
|
||||
it('should only return label identifier, id, and image identifier fields when flag is true', () => {
|
||||
const idField = createMockField({
|
||||
id: 'field-id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
});
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const emailField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const domainNameField = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'domainName',
|
||||
type: FieldMetadataType.LINKS,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
idField,
|
||||
nameField,
|
||||
emailField,
|
||||
domainNameField,
|
||||
]);
|
||||
const flatObjectMetadata = {
|
||||
...buildFlatObjectMetadata([
|
||||
'field-id',
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
]),
|
||||
labelIdentifierFieldMetadataId: 'field-1',
|
||||
imageIdentifierFieldMetadataId: 'field-3',
|
||||
};
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: true,
|
||||
name: true,
|
||||
domainName: {
|
||||
primaryLinkLabel: true,
|
||||
primaryLinkUrl: true,
|
||||
secondaryLinks: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include domainName for company objects even if not image identifier', () => {
|
||||
const idField = createMockField({
|
||||
id: 'field-id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
});
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const domainNameField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'domainName',
|
||||
type: FieldMetadataType.LINKS,
|
||||
});
|
||||
const emailField = createMockField({
|
||||
id: 'field-3',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
idField,
|
||||
nameField,
|
||||
domainNameField,
|
||||
emailField,
|
||||
]);
|
||||
const flatObjectMetadata = {
|
||||
...buildFlatObjectMetadata([
|
||||
'field-id',
|
||||
'field-1',
|
||||
'field-2',
|
||||
'field-3',
|
||||
]),
|
||||
nameSingular: 'company',
|
||||
labelIdentifierFieldMetadataId: 'field-1',
|
||||
};
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: true,
|
||||
name: true,
|
||||
domainName: {
|
||||
primaryLinkLabel: true,
|
||||
primaryLinkUrl: true,
|
||||
secondaryLinks: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect restrictions even with onlyUseLabelIdentifierFields flag', () => {
|
||||
const idField = createMockField({
|
||||
id: 'field-id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
});
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const emailField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
idField,
|
||||
nameField,
|
||||
emailField,
|
||||
]);
|
||||
const flatObjectMetadata = {
|
||||
...buildFlatObjectMetadata(['field-id', 'field-1', 'field-2']),
|
||||
labelIdentifierFieldMetadataId: 'field-1',
|
||||
};
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-1': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations: true,
|
||||
});
|
||||
|
||||
// Label identifier field is restricted, so it should not be returned
|
||||
// even with the flag
|
||||
expect(result).toEqual({
|
||||
id: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MANY_TO_ONE relation fields', () => {
|
||||
it('should return join column name for MANY_TO_ONE relations', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const companyRelationField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
joinColumnName: 'companyId',
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
companyRelationField,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
companyId: true, // join column name instead of relation name
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return join column if relation field is restricted', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const companyRelationField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
joinColumnName: 'companyId',
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
companyRelationField,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include ONE_TO_MANY relation fields as regular fields', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const peopleRelationField = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'people',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
peopleRelationField,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
people: true, // ONE_TO_MANY relations are included as regular fields
|
||||
});
|
||||
});
|
||||
|
||||
it('should include MANY_TO_ONE relations without joinColumnName as regular fields', () => {
|
||||
const nameField = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const relationFieldWithoutJoinColumn = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
settings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
},
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
nameField,
|
||||
relationFieldWithoutJoinColumn,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
company: true, // MANY_TO_ONE without joinColumnName are included as regular fields
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty field list', () => {
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle all fields restricted', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-1': { canRead: false },
|
||||
'field-2': { canRead: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle canRead: true in restrictedFields', () => {
|
||||
const field1 = createMockField({
|
||||
id: 'field-1',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
const field2 = createMockField({
|
||||
id: 'field-2',
|
||||
name: 'email',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
|
||||
field1,
|
||||
field2,
|
||||
]);
|
||||
const flatObjectMetadata = buildFlatObjectMetadata([
|
||||
'field-1',
|
||||
'field-2',
|
||||
]);
|
||||
|
||||
const result = getAllSelectableFields({
|
||||
restrictedFields: {
|
||||
'field-1': { canRead: true, canUpdate: false },
|
||||
'field-2': { canRead: false, canUpdate: false },
|
||||
},
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
checkIfFieldIsImageIdentifier,
|
||||
checkIfFieldIsLabelIdentifier,
|
||||
} from 'twenty-shared/metadata';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
RelationType,
|
||||
type RestrictedFieldsPermissions,
|
||||
compositeTypeDefinitions,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
type SelectableFieldsStructured = Record<
|
||||
string,
|
||||
boolean | Record<string, boolean>
|
||||
>;
|
||||
|
||||
export const getAllSelectableFields = ({
|
||||
restrictedFields,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
onlyUseLabelIdentifierFieldsInRelations = false,
|
||||
}: {
|
||||
restrictedFields: RestrictedFieldsPermissions;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
onlyUseLabelIdentifierFieldsInRelations?: boolean;
|
||||
}): SelectableFieldsStructured => {
|
||||
const result: SelectableFieldsStructured = {};
|
||||
|
||||
for (const fieldId of flatObjectMetadata.fieldIds) {
|
||||
const flatField = findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: fieldId,
|
||||
});
|
||||
|
||||
if (restrictedFields[flatField.id]?.canRead === false) continue;
|
||||
|
||||
if (onlyUseLabelIdentifierFieldsInRelations) {
|
||||
const fieldIsLabelIdentifier = checkIfFieldIsLabelIdentifier(
|
||||
flatField,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
const fieldIsImageIdentifier = checkIfFieldIsImageIdentifier(
|
||||
flatField,
|
||||
flatObjectMetadata,
|
||||
);
|
||||
const fieldIsIdField = flatField.name === 'id';
|
||||
|
||||
if (
|
||||
!fieldIsLabelIdentifier &&
|
||||
!fieldIsImageIdentifier &&
|
||||
!fieldIsIdField
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(flatField.type)) {
|
||||
const compositeType = compositeTypeDefinitions.get(flatField.type);
|
||||
|
||||
if (!compositeType) {
|
||||
throw new Error(
|
||||
`Composite type definition not found for type: ${flatField.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const compositeFields: Record<string, boolean> = {};
|
||||
|
||||
for (const property of compositeType.properties) {
|
||||
compositeFields[property.name] = true;
|
||||
}
|
||||
|
||||
result[flatField.name] = compositeFields;
|
||||
} else if (
|
||||
isFlatFieldMetadataOfType(flatField, FieldMetadataType.RELATION) &&
|
||||
flatField.settings.relationType === RelationType.MANY_TO_ONE &&
|
||||
isDefined(flatField.settings.joinColumnName)
|
||||
) {
|
||||
result[flatField.settings.joinColumnName] = true;
|
||||
} else {
|
||||
result[flatField.name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const getIsFlatFieldAJoinColumn = ({
|
||||
flatField,
|
||||
}: {
|
||||
flatField: FlatFieldMetadata;
|
||||
}): boolean => {
|
||||
const flatFieldIsJoinColumn =
|
||||
isDefined(flatField.settings) &&
|
||||
'junctionTargetFieldId' in flatField.settings;
|
||||
|
||||
// TODO: refactor this when we remove hard-coded activity relations
|
||||
const flatFieldIsActivityTarget =
|
||||
flatField.name === 'noteTargets' || flatField.name === 'taskTargets';
|
||||
|
||||
return flatFieldIsJoinColumn || flatFieldIsActivityTarget;
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { RelationType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const getIsFlatFieldAJunctionRelationField = ({
|
||||
flatField,
|
||||
}: {
|
||||
flatField: FlatFieldMetadata;
|
||||
}): boolean => {
|
||||
const isJunctionRelationField =
|
||||
isDefined(flatField.settings) &&
|
||||
'relationType' in flatField.settings &&
|
||||
flatField.settings.relationType === RelationType.MANY_TO_ONE &&
|
||||
isDefined(flatField.settings.joinColumnName);
|
||||
|
||||
// TODO: refactor this when we remove hard-coded activity relations
|
||||
const isActivityRelationField =
|
||||
flatField.name === 'note' || flatField.name === 'task';
|
||||
|
||||
return isJunctionRelationField || isActivityRelationField;
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant';
|
||||
import { type Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
export const getShouldRecurseIntoRelation = ({
|
||||
depth,
|
||||
flatField,
|
||||
}: {
|
||||
depth: Depth | undefined;
|
||||
flatField: FlatFieldMetadata;
|
||||
}): boolean => {
|
||||
const flatFieldIsJoinColumn =
|
||||
isDefined(flatField.settings) &&
|
||||
'junctionTargetFieldId' in flatField.settings;
|
||||
|
||||
// TODO: refactor this when we remove hard-coded activity relations
|
||||
const flatFieldIsActivityTarget =
|
||||
flatField.name === 'noteTargets' || flatField.name === 'taskTargets';
|
||||
|
||||
const shouldGoOneLevelDeeper =
|
||||
depth === MAX_DEPTH && isDefined(flatField.relationTargetObjectMetadataId);
|
||||
|
||||
const shouldRecurseIntoRelation =
|
||||
shouldGoOneLevelDeeper ||
|
||||
flatFieldIsActivityTarget ||
|
||||
flatFieldIsJoinColumn;
|
||||
|
||||
return shouldRecurseIntoRelation;
|
||||
};
|
||||
@@ -2,12 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { CommonArgsProcessors } from 'src/engine/api/common/common-args-processors/common-args-processors';
|
||||
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
|
||||
import { CommonQueryRunners } from 'src/engine/api/common/common-query-runners/common-query-runners';
|
||||
import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service';
|
||||
import { GroupByWithRecordsService } from 'src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service';
|
||||
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 { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
|
||||
import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module';
|
||||
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
|
||||
Reference in New Issue
Block a user