Browse Source

perf(core): Database access performance & edge case fixes (#2744)

Eugene Nitsenko 1 year ago
parent
commit
48b239bcfa

+ 4 - 1
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<string, CustomFieldConfig[]> {
         return this.serverConfigService.customFieldsMap;

+ 4 - 1
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

+ 16 - 21
packages/core/e2e/shop-order.e2e-spec.ts

@@ -161,9 +161,8 @@ describe('Shop orders', () => {
             },
         );
 
-        const result = await shopClient.query<CodegenShop.GetAvailableCountriesQuery>(
-            GET_AVAILABLE_COUNTRIES,
-        );
+        const result =
+            await shopClient.query<CodegenShop.GetAvailableCountriesQuery>(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<CodegenShop.GetActiveOrderQuery>(
-                    GET_ACTIVE_ORDER,
-                );
+                const activeOrderResult =
+                    await shopClient.query<CodegenShop.GetActiveOrderQuery>(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<CodegenShop.GetActiveOrderQuery>(
-                    GET_ACTIVE_ORDER,
-                );
+                const activeOrderResult =
+                    await shopClient.query<CodegenShop.GetActiveOrderQuery>(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<CodegenShop.GetActiveOrderPaymentsQuery>(
-                    GET_ACTIVE_ORDER_PAYMENTS,
-                );
+                const result =
+                    await shopClient.query<CodegenShop.GetActiveOrderPaymentsQuery>(
+                        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<CodegenShop.GetCustomerAddressesQuery>(
-                GET_ACTIVE_ORDER_ADDRESSES,
-            );
+            const { activeOrder } =
+                await shopClient.query<CodegenShop.GetCustomerAddressesQuery>(GET_ACTIVE_ORDER_ADDRESSES);
 
             expect(activeOrder!.customer!.addresses).toEqual([]);
         });
@@ -2087,9 +2084,8 @@ describe('Shop orders', () => {
                 },
             });
 
-            const { activeOrder } = await shopClient.query<CodegenShop.GetCustomerOrdersQuery>(
-                GET_ACTIVE_ORDER_ORDERS,
-            );
+            const { activeOrder } =
+                await shopClient.query<CodegenShop.GetCustomerOrdersQuery>(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<Codegen.GetShippingMethodListQuery>(
-                GET_SHIPPING_METHOD_LIST,
-            );
+            const { shippingMethods } =
+                await adminClient.query<Codegen.GetShippingMethodListQuery>(GET_SHIPPING_METHOD_LIST);
             for (const method of shippingMethods.items) {
                 await adminClient.query<
                     Codegen.DeleteShippingMethodMutation,

+ 2 - 2
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;
     }

+ 2 - 1
packages/core/src/api/resolvers/admin/collection.resolver.ts

@@ -101,7 +101,8 @@ export class CollectionResolver {
     ): Promise<Translated<Collection>> {
         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()

+ 40 - 16
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<T> = {},
     ) {
-        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<VendureEntity>,
+                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 })

+ 3 - 4
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<any> = 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]));

+ 2 - 133
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<T>} 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<T extends VendureEntity>(
         qb: SelectQueryBuilder<T>,
         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<T>} qb The query builder instance to which the relations will be joined.
-     * @param {EntityTarget<T>} 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<string>} 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<T extends VendureEntity>(
-        qb: SelectQueryBuilder<T>,
-        entity: EntityTarget<T>,
-        requestedRelations: string[] = [],
-    ): Set<string> {
-        const sourceMetadata = qb.connection.getMetadata(entity);
-        const isTreeSourceMetadata = this.isTreeEntityMetadata(sourceMetadata)
-        const processedRelations = new Set<string>();
-
-        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;
-    }
 }

+ 2 - 0
packages/core/src/service/helpers/translatable-saver/translatable-saver.ts

@@ -94,6 +94,8 @@ export class TranslatableSaver {
     async update<T extends Translatable & VendureEntity>(options: UpdateTranslatableOptions<T>): Promise<T> {
         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<Translation<T>>);

+ 157 - 0
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<T>} qb - The query builder instance for joining relations.
+ * @param {EntityTarget<T>} 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<string, string>} - 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<T extends VendureEntity>(
+    qb: SelectQueryBuilder<T>,
+    entity: EntityTarget<T>,
+    requestedRelations: string[] = [],
+    maxEagerDepth: number = 1,
+): Map<string, string> {
+    const joinedRelations = new Map<string, string>();
+    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;
+}