diff --git a/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts b/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts index ca6e7f8c9d..b9df060dde 100644 --- a/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts +++ b/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts @@ -17,7 +17,10 @@ import { isEntityCreateOrUpdateMutation } from '../utils/is-entity-create-or-upd @Injectable() export class BaseDataService { - constructor(private apollo: Apollo, private serverConfigService: ServerConfigService) {} + constructor( + private apollo: Apollo, + private serverConfigService: ServerConfigService, + ) {} private get customFields(): Map { return this.serverConfigService.customFieldsMap; diff --git a/packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts b/packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts index 8e4711f4fa..2b2fab4064 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts +++ b/packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts @@ -50,7 +50,10 @@ export class AssetsComponent { @Input() updatePermissions: string | string[] | Permission | Permission[]; - constructor(private modalService: ModalService, private changeDetector: ChangeDetectorRef) {} + constructor( + private modalService: ModalService, + private changeDetector: ChangeDetectorRef, + ) {} selectAssets() { this.modalService diff --git a/packages/core/e2e/shop-order.e2e-spec.ts b/packages/core/e2e/shop-order.e2e-spec.ts index 3072cb3fab..8d2e90f932 100644 --- a/packages/core/e2e/shop-order.e2e-spec.ts +++ b/packages/core/e2e/shop-order.e2e-spec.ts @@ -161,9 +161,8 @@ describe('Shop orders', () => { }, ); - const result = await shopClient.query( - GET_AVAILABLE_COUNTRIES, - ); + const result = + await shopClient.query(GET_AVAILABLE_COUNTRIES); expect(result.availableCountries.length).toBe(countries.items.length - 1); expect(result.availableCountries.find(c => c.id === AT.id)).toBeUndefined(); }); @@ -1519,9 +1518,8 @@ describe('Shop orders', () => { id: shippingMethods[1].id, }); - const activeOrderResult = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const activeOrderResult = + await shopClient.query(GET_ACTIVE_ORDER); const order = activeOrderResult.activeOrder!; @@ -1533,9 +1531,8 @@ describe('Shop orders', () => { }); it('shipping method is preserved after adjustOrderLine', async () => { - const activeOrderResult = await shopClient.query( - GET_ACTIVE_ORDER, - ); + const activeOrderResult = + await shopClient.query(GET_ACTIVE_ORDER); activeOrder = activeOrderResult.activeOrder!; const { adjustOrderLine } = await shopClient.query< CodegenShop.AdjustItemQuantityMutation, @@ -1709,9 +1706,10 @@ describe('Shop orders', () => { expect(addPaymentToOrder.errorCode).toBe(ErrorCode.PAYMENT_FAILED_ERROR); expect((addPaymentToOrder as any).paymentErrorMessage).toBe('Something went horribly wrong'); - const result = await shopClient.query( - GET_ACTIVE_ORDER_PAYMENTS, - ); + const result = + await shopClient.query( + GET_ACTIVE_ORDER_PAYMENTS, + ); const payment = result.activeOrder!.payments![1]; expect(result.activeOrder!.payments!.length).toBe(2); expect(payment.method).toBe(testErrorPaymentMethod.code); @@ -2059,9 +2057,8 @@ describe('Shop orders', () => { }, }); - const { activeOrder } = await shopClient.query( - GET_ACTIVE_ORDER_ADDRESSES, - ); + const { activeOrder } = + await shopClient.query(GET_ACTIVE_ORDER_ADDRESSES); expect(activeOrder!.customer!.addresses).toEqual([]); }); @@ -2087,9 +2084,8 @@ describe('Shop orders', () => { }, }); - const { activeOrder } = await shopClient.query( - GET_ACTIVE_ORDER_ORDERS, - ); + const { activeOrder } = + await shopClient.query(GET_ACTIVE_ORDER_ORDERS); expect(activeOrder!.customer!.orders.items).toEqual([]); }); @@ -2328,9 +2324,8 @@ describe('Shop orders', () => { beforeAll(async () => { // First we will remove all ShippingMethods and set up 2 specialized ones - const { shippingMethods } = await adminClient.query( - GET_SHIPPING_METHOD_LIST, - ); + const { shippingMethods } = + await adminClient.query(GET_SHIPPING_METHOD_LIST); for (const method of shippingMethods.items) { await adminClient.query< Codegen.DeleteShippingMethodMutation, diff --git a/packages/core/src/api/common/custom-field-relation-resolver.service.ts b/packages/core/src/api/common/custom-field-relation-resolver.service.ts index 28b9b14f57..7fb46568b1 100644 --- a/packages/core/src/api/common/custom-field-relation-resolver.service.ts +++ b/packages/core/src/api/common/custom-field-relation-resolver.service.ts @@ -76,8 +76,8 @@ export class CustomFieldRelationResolverService { const translated: any = Array.isArray(result) ? result.map(r => (this.isTranslatable(r) ? this.translator.translate(r, ctx) : r)) : this.isTranslatable(result) - ? this.translator.translate(result, ctx) - : result; + ? this.translator.translate(result, ctx) + : result; return translated; } diff --git a/packages/core/src/api/resolvers/admin/collection.resolver.ts b/packages/core/src/api/resolvers/admin/collection.resolver.ts index 8f73bd2fa6..4cc8a02795 100644 --- a/packages/core/src/api/resolvers/admin/collection.resolver.ts +++ b/packages/core/src/api/resolvers/admin/collection.resolver.ts @@ -101,7 +101,8 @@ export class CollectionResolver { ): Promise> { const { input } = args; this.configurableOperationCodec.decodeConfigurableOperationIds(CollectionFilter, input.filters); - return this.collectionService.create(ctx, input); + const collection = await this.collectionService.create(ctx, input); + return collection; } @Transaction() diff --git a/packages/core/src/connection/transactional-connection.ts b/packages/core/src/connection/transactional-connection.ts index 948e04e3d6..63366fc4a2 100644 --- a/packages/core/src/connection/transactional-connection.ts +++ b/packages/core/src/connection/transactional-connection.ts @@ -1,17 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { InjectConnection } from '@nestjs/typeorm'; +import { InjectDataSource } from '@nestjs/typeorm/dist/common/typeorm.decorators'; import { ID, Type } from '@vendure/common/lib/shared-types'; import { - Connection, + DataSource, EntityManager, EntitySchema, FindOneOptions, - FindOptionsUtils, + FindManyOptions, ObjectLiteral, ObjectType, Repository, + SelectQueryBuilder, } from 'typeorm'; -import { FindManyOptions } from 'typeorm/find-options/FindManyOptions'; import { RequestContext } from '../api/common/request-context'; import { TransactionIsolationLevel } from '../api/decorators/transaction.decorator'; @@ -19,6 +19,7 @@ import { TRANSACTION_MANAGER_KEY } from '../common/constants'; import { EntityNotFoundError } from '../common/error/errors'; import { ChannelAware, SoftDeletable } from '../common/types/common-types'; import { VendureEntity } from '../entity/base/base.entity'; +import { joinTreeRelationsDynamically } from '../service/helpers/utils/tree-relations-qb-joiner'; import { TransactionWrapper } from './transaction-wrapper'; import { GetEntityOrThrowOptions } from './types'; @@ -38,7 +39,7 @@ import { GetEntityOrThrowOptions } from './types'; @Injectable() export class TransactionalConnection { constructor( - @InjectConnection() private connection: Connection, + @InjectDataSource() private dataSource: DataSource, private transactionWrapper: TransactionWrapper, ) {} @@ -48,8 +49,8 @@ export class TransactionalConnection { * performed with this connection will not be performed within any outer * transactions. */ - get rawConnection(): Connection { - return this.connection; + get rawConnection(): DataSource { + return this.dataSource; } /** @@ -275,16 +276,26 @@ export class TransactionalConnection { channelId: ID, options: FindOneOptions = {}, ) { - const qb = this.getRepository(ctx, entity).createQueryBuilder('entity').setFindOptions(options); - if (options.loadEagerRelations !== false) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata); + const qb = this.getRepository(ctx, entity).createQueryBuilder('entity'); + + if (Array.isArray(options.relations) && options.relations.length > 0) { + const joinedRelations = joinTreeRelationsDynamically(qb, entity, options.relations); + // Remove any relations which are related to the 'collection' tree, as these are handled separately + // to avoid duplicate joins. + options.relations = options.relations.filter(relationPath => !joinedRelations.has(relationPath)); } + qb.setFindOptions({ + relationLoadStrategy: 'query', // default to query strategy for maximum performance + ...options, + }); + qb.leftJoin('entity.channels', '__channel') .andWhere('entity.id = :id', { id }) .andWhere('__channel.id = :channelId', { channelId }); - return qb.getOne().then(result => result ?? undefined); + return qb.getOne().then(result => { + return result ?? undefined; + }); } /** @@ -305,11 +316,24 @@ export class TransactionalConnection { return Promise.resolve([]); } - const qb = this.getRepository(ctx, entity).createQueryBuilder('entity').setFindOptions(options); - if (options.loadEagerRelations !== false) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata); + const qb = this.getRepository(ctx, entity).createQueryBuilder('entity'); + + if (Array.isArray(options.relations) && options.relations.length > 0) { + const joinedRelations = joinTreeRelationsDynamically( + qb as SelectQueryBuilder, + entity, + options.relations, + ); + // Remove any relations which are related to the 'collection' tree, as these are handled separately + // to avoid duplicate joins. + options.relations = options.relations.filter(relationPath => !joinedRelations.has(relationPath)); } + + qb.setFindOptions({ + relationLoadStrategy: 'query', // default to query strategy for maximum performance + ...options, + }); + return qb .leftJoin('entity.channels', 'channel') .andWhere('entity.id IN (:...ids)', { ids }) diff --git a/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts b/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts index be453e6852..0132e06333 100644 --- a/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts +++ b/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts @@ -9,9 +9,9 @@ import { InternalServerError } from '../../../common/error/errors'; import { TransactionalConnection } from '../../../connection/transactional-connection'; import { VendureEntity } from '../../../entity/base/base.entity'; import { ProductVariant } from '../../../entity/product-variant/product-variant.entity'; -import { ListQueryBuilder } from '../list-query-builder/list-query-builder'; import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator'; import { TranslatorService } from '../translator/translator.service'; +import { joinTreeRelationsDynamically } from '../utils/tree-relations-qb-joiner'; import { HydrateOptions } from './entity-hydrator-types'; @@ -80,7 +80,6 @@ export class EntityHydrator { private connection: TransactionalConnection, private productPriceApplicator: ProductPriceApplicator, private translator: TranslatorService, - private listQueryBuilder: ListQueryBuilder, ) {} /** @@ -122,7 +121,7 @@ export class EntityHydrator { const hydratedQb: SelectQueryBuilder = this.connection .getRepository(ctx, target.constructor) .createQueryBuilder(target.constructor.name); - const processedRelations = this.listQueryBuilder.joinTreeRelationsDynamically( + const joinedRelations = joinTreeRelationsDynamically( hydratedQb, target.constructor, missingRelations, @@ -130,7 +129,7 @@ export class EntityHydrator { hydratedQb.setFindOptions({ relationLoadStrategy: 'query', where: { id: target.id }, - relations: missingRelations.filter(relationPath => !processedRelations.has(relationPath)), + relations: missingRelations.filter(relationPath => !joinedRelations.has(relationPath)), }); const hydrated = await hydratedQb.getOne(); const propertiesToAdd = unique(missingRelations.map(relation => relation.split('.')[0])); diff --git a/packages/core/src/service/helpers/list-query-builder/list-query-builder.ts b/packages/core/src/service/helpers/list-query-builder/list-query-builder.ts index 8545b57627..27c9b8262c 100644 --- a/packages/core/src/service/helpers/list-query-builder/list-query-builder.ts +++ b/packages/core/src/service/helpers/list-query-builder/list-query-builder.ts @@ -31,6 +31,7 @@ import { getColumnMetadata, getEntityAlias } from './connection-utils'; import { getCalculatedColumns } from './get-calculated-columns'; import { parseFilterParams, WhereGroup } from './parse-filter-params'; import { parseSortParams } from './parse-sort-params'; +import { joinTreeRelationsDynamically } from '../utils/tree-relations-qb-joiner'; /** * @description @@ -269,7 +270,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap { // and requires special handling to ensure that only the necessary relations are joined. // This is bypassed an issue in TypeORM where it would join the same relation multiple times. // See https://github.com/typeorm/typeorm/issues/9936 for more context. - const processedRelations = this.joinTreeRelationsDynamically(qb, entity, relations); + const processedRelations = joinTreeRelationsDynamically(qb, entity, relations); // Remove any relations which are related to the 'collection' tree, as these are handled separately // to avoid duplicate joins. @@ -634,142 +635,10 @@ export class ListQueryBuilder implements OnApplicationBootstrap { } } - /** - * These method are designed to address specific challenges encountered with TypeORM - * when dealing with complex relation structures, particularly around the 'collection' - * entity and other similar entities, and they nested relations ('parent', 'children'). The need for these custom - * implementations arises from limitations in handling deeply nested relations and ensuring - * efficient query generation without duplicate joins, as discussed in TypeORM issue #9936. - * See https://github.com/typeorm/typeorm/issues/9936 for more context. - */ - - /** - * Verifies if a relation has already been joined in a query builder to prevent duplicate joins. - * This method ensures query efficiency and correctness by maintaining unique joins within the query builder. - * - * @param {SelectQueryBuilder} qb The query builder instance where the joins are being added. - * @param {string} alias The join alias to check for uniqueness. This alias is used to determine if the relation - * has already been joined to avoid adding duplicate join statements. - * @returns boolean Returns true if the relation has already been joined (based on the alias), false otherwise. - * @template T extends VendureEntity The entity type for which the query builder is configured. - */ private isRelationAlreadyJoined( qb: SelectQueryBuilder, alias: string, ): boolean { return qb.expressionMap.joinAttributes.some(ja => ja.alias.name === alias); } - - /** - * @description - * Check if the current entity has one or more self-referencing relations - * to determine if it is a tree type or has tree relations. - * @param metadata - * @private - */ - private isTreeEntityMetadata(metadata: EntityMetadata): boolean { - if (metadata.treeType !== undefined) { - return true; - } - - for (const relation of metadata.relations) { - if (relation.isTreeParent || relation.isTreeChildren) { - return true; - } - if (relation.inverseEntityMetadata === metadata) { - return true; - } - } - return false - } - - /** - * Dynamically joins tree relations and their eager relations to a query builder. This method is specifically - * designed for entities utilizing TypeORM tree decorators (@TreeParent, @TreeChildren) and aims to address - * the challenge of efficiently managing deeply nested relations and avoiding duplicate joins. The method - * automatically handles the joining of related entities marked with tree relation decorators and eagerly - * loaded relations, ensuring efficient data retrieval and query generation. - * - * The method iterates over the requested relations paths, joining each relation dynamically. For tree relations, - * it also recursively joins all associated eager relations. This approach avoids the manual specification of joins - * and leverages TypeORM's relation metadata to automate the process. - * - * @param {SelectQueryBuilder} qb The query builder instance to which the relations will be joined. - * @param {EntityTarget} entity The target entity class or schema name. This parameter is used to access - * the entity's metadata and analyze its relations. - * @param {string[]} requestedRelations An array of strings representing the relation paths to be dynamically joined. - * Each string in the array should denote a path to a relation (e.g., 'parent.parent.children'). - * @returns {Set} A Set containing the paths of relations that were dynamically joined. This set can be used - * to track which relations have been processed and potentially avoid duplicate processing. - * @template T extends VendureEntity The type of the entity for which relations are being joined. This type parameter - * should extend VendureEntity to ensure compatibility with Vendure's data access layer. - */ - public joinTreeRelationsDynamically( - qb: SelectQueryBuilder, - entity: EntityTarget, - requestedRelations: string[] = [], - ): Set { - const sourceMetadata = qb.connection.getMetadata(entity); - const isTreeSourceMetadata = this.isTreeEntityMetadata(sourceMetadata) - const processedRelations = new Set(); - - const processRelation = ( - currentMetadata: EntityMetadata, - currentPath: string, - currentAlias: string, - ) => { - if (!this.isTreeEntityMetadata(currentMetadata) && !isTreeSourceMetadata) { - return; - } - - const parts = currentPath.split('.'); - const part = parts.shift(); - - if (!part || !currentMetadata) return; - - const relationMetadata = currentMetadata.findRelationWithPropertyPath(part); - if (relationMetadata) { - const isEager = relationMetadata.isEager; - let joinConnector = '_'; - if (isEager) { - joinConnector = '__'; - } - const nextAlias = `${currentAlias}${joinConnector}${part}`; - const nextPath = parts.join('.'); - - if (!this.isRelationAlreadyJoined(qb, nextAlias)) { - qb.leftJoinAndSelect(`${currentAlias}.${part}`, nextAlias); - } - - const isTree = this.isTreeEntityMetadata(relationMetadata.inverseEntityMetadata); - - if (isTree) { - relationMetadata.inverseEntityMetadata.relations.forEach(subRelation => { - if (subRelation.isEager) { - processRelation( - relationMetadata.inverseEntityMetadata, - subRelation.propertyPath, - nextAlias, - ); - } - }); - } - - if (nextPath) { - processRelation( - relationMetadata.inverseEntityMetadata, - nextPath, - nextAlias, - ); - } - processedRelations.add(currentPath); - } - }; - - requestedRelations.forEach(relationPath => { - processRelation(sourceMetadata, relationPath, qb.alias); - }); - - return processedRelations; - } } diff --git a/packages/core/src/service/helpers/translatable-saver/translatable-saver.ts b/packages/core/src/service/helpers/translatable-saver/translatable-saver.ts index d6563215e4..1560eed689 100644 --- a/packages/core/src/service/helpers/translatable-saver/translatable-saver.ts +++ b/packages/core/src/service/helpers/translatable-saver/translatable-saver.ts @@ -94,6 +94,8 @@ export class TranslatableSaver { async update(options: UpdateTranslatableOptions): Promise { const { ctx, entityType, translationType, input, beforeSave, typeOrmSubscriberData } = options; const existingTranslations = await this.connection.getRepository(ctx, translationType).find({ + relationLoadStrategy: 'query', + loadEagerRelations: false, where: { base: { id: input.id } }, relations: ['base'], } as FindManyOptions>); diff --git a/packages/core/src/service/helpers/utils/tree-relations-qb-joiner.ts b/packages/core/src/service/helpers/utils/tree-relations-qb-joiner.ts new file mode 100644 index 0000000000..35d4e7fbdc --- /dev/null +++ b/packages/core/src/service/helpers/utils/tree-relations-qb-joiner.ts @@ -0,0 +1,157 @@ +import { EntityMetadata, SelectQueryBuilder } from 'typeorm'; +import { EntityTarget } from 'typeorm/common/EntityTarget'; + +import { VendureEntity } from '../../../entity'; + +/** + * @description + * Check if the current entity has one or more self-referencing relations + * to determine if it is a tree type or has tree relations. + * @param metadata + * @private + */ +function isTreeEntityMetadata(metadata: EntityMetadata): boolean { + if (metadata.treeType !== undefined) { + return true; + } + + for (const relation of metadata.relations) { + if (relation.isTreeParent || relation.isTreeChildren) { + return true; + } + if (relation.inverseEntityMetadata === metadata) { + return true; + } + } + return false; +} + +/** + * Dynamically joins tree relations and their eager counterparts in a TypeORM SelectQueryBuilder, addressing + * challenges of managing deeply nested relations and optimizing query efficiency. It leverages TypeORM tree + * decorators (@TreeParent, @TreeChildren) to automate joins of self-related entities, including those marked for eager loading. + * The process avoids duplicate joins and manual join specifications by using relation metadata. + * + * @param {SelectQueryBuilder} qb - The query builder instance for joining relations. + * @param {EntityTarget} entity - The target entity class or schema name, used to access entity metadata. + * @param {string[]} [requestedRelations=[]] - An array of relation paths (e.g., 'parent.children') to join dynamically. + * @param {number} [maxEagerDepth=1] - Limits the depth of eager relation joins to avoid excessively deep joins. + * @returns {Map} - A Map of joined relation paths to their aliases, aiding in tracking and preventing duplicates. + * @template T - The entity type, extending VendureEntity for compatibility with Vendure's data layer. + * + * Usage Notes: + * - Only entities utilizing TypeORM tree decorators and having nested relations are supported. + * - The `maxEagerDepth` parameter controls the recursion depth for eager relations, preventing performance issues. + * + * For more context on the issue this function addresses, refer to TypeORM issue #9936: + * https://github.com/typeorm/typeorm/issues/9936 + * + * Example: + * ```typescript + * const qb = repository.createQueryBuilder("entity"); + * joinTreeRelationsDynamically(qb, EntityClass, ["parent.children"], 2); + * ``` + */ +export function joinTreeRelationsDynamically( + qb: SelectQueryBuilder, + entity: EntityTarget, + requestedRelations: string[] = [], + maxEagerDepth: number = 1, +): Map { + const joinedRelations = new Map(); + if (!requestedRelations.length) { + return joinedRelations; + } + + const sourceMetadata = qb.connection.getMetadata(entity); + const sourceMetadataIsTree = isTreeEntityMetadata(sourceMetadata); + + const processRelation = ( + currentMetadata: EntityMetadata, + parentMetadataIsTree: boolean, + currentPath: string, + currentAlias: string, + parentPath?: string[], + eagerDepth: number = 0, + ) => { + if (currentPath === '') { + return; + } + parentPath = parentPath?.filter(p => p !== ''); + const currentMetadataIsTree = + isTreeEntityMetadata(currentMetadata) || sourceMetadataIsTree || parentMetadataIsTree; + if (!currentMetadataIsTree) { + return; + } + + const parts = currentPath.split('.'); + let part = parts.shift(); + + if (!part || !currentMetadata) return; + + if (part === 'customFields' && parts.length > 0) { + const relation = parts.shift(); + if (!relation) return; + part += `.${relation}`; + } + + const relationMetadata = currentMetadata.findRelationWithPropertyPath(part); + + if (!relationMetadata) { + return; + } + + let joinConnector = '_'; + if (relationMetadata.isEager) { + joinConnector = '__'; + } + const nextAlias = `${currentAlias}${joinConnector}${part.replace(/\./g, '_')}`; + const nextPath = parts.join('.'); + const fullPath = [...(parentPath || []), part].join('.'); + if (!qb.expressionMap.joinAttributes.some(ja => ja.alias.name === nextAlias)) { + qb.leftJoinAndSelect(`${currentAlias}.${part}`, nextAlias); + joinedRelations.set(fullPath, nextAlias); + } + + const inverseEntityMetadataIsTree = isTreeEntityMetadata(relationMetadata.inverseEntityMetadata); + + if (!currentMetadataIsTree && !inverseEntityMetadataIsTree) { + return; + } + + const newEagerDepth = relationMetadata.isEager ? eagerDepth + 1 : eagerDepth; + + if (newEagerDepth <= maxEagerDepth) { + relationMetadata.inverseEntityMetadata.relations.forEach(subRelation => { + if (subRelation.isEager) { + processRelation( + relationMetadata.inverseEntityMetadata, + currentMetadataIsTree, + subRelation.propertyPath, + nextAlias, + [fullPath], + newEagerDepth, + ); + } + }); + } + + if (nextPath) { + processRelation( + relationMetadata.inverseEntityMetadata, + currentMetadataIsTree, + nextPath, + nextAlias, + [fullPath], + ); + } + }; + + requestedRelations.forEach(relationPath => { + if (!joinedRelations.has(relationPath)) { + processRelation(sourceMetadata, sourceMetadataIsTree, relationPath, qb.alias); + } + }); + + return joinedRelations; +}