Explorar el Código

fix(dashboard, core): Improve collection query performance

Will Nahmens hace 2 semanas
padre
commit
5d7df586d9

+ 36 - 1
packages/core/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -1,11 +1,12 @@
 import { Logger } from '@nestjs/common';
-import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
+import { Args, Info, Parent, ResolveField, Resolver } from '@nestjs/graphql';
 import {
     CollectionBreadcrumb,
     ConfigurableOperation,
     ProductVariantListOptions,
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
+import { GraphQLResolveInfo } from 'graphql';
 
 import { ListQueryOptions } from '../../../common/types/common-types';
 import { Translated } from '../../../common/types/locale-types';
@@ -59,7 +60,23 @@ export class CollectionEntityResolver {
         @Args() args: { options: ProductVariantListOptions },
         @Api() apiType: ApiType,
         @Relations({ entity: ProductVariant, omit: ['assets'] }) relations: RelationPaths<ProductVariant>,
+        @Info() info: GraphQLResolveInfo,
     ): Promise<PaginatedList<Translated<ProductVariant>>> {
+        const onlyTotalItems = this.isOnlyTotalItemsRequested(info);
+
+        if (onlyTotalItems) {
+            // On top level (e.g. viewing collections) only retrieve count
+            Logger.log(`Only retrieving total items for collection ${collection.id}`);
+            const totalItems = await this.productVariantService.getVariantCountByCollectionId(
+                ctx,
+                collection.id,
+            );
+            return {
+                items: [],
+                totalItems,
+            };
+        }
+
         let options: ListQueryOptions<Product> = args.options;
         if (apiType === 'shop') {
             options = {
@@ -73,6 +90,24 @@ export class CollectionEntityResolver {
         return this.productVariantService.getVariantsByCollectionId(ctx, collection.id, options, relations);
     }
 
+    private isOnlyTotalItemsRequested(info: GraphQLResolveInfo): boolean {
+        const fieldNode = info.fieldNodes[0];
+        const selectionSet = fieldNode.selectionSet;
+
+        if (!selectionSet) return false;
+
+        const selections = selectionSet.selections;
+        const hasItems = selections.some(s => s.kind === 'Field' && s.name.value === 'items');
+        const hasTotalItems = selections.some(s => s.kind === 'Field' && s.name.value === 'totalItems');
+
+        // Check if only totalItems is requested (ignoring __typename which is auto-added)
+        const hasOtherFields = selections.some(
+            s => s.kind === 'Field' && s.name.value !== 'totalItems' && s.name.value !== '__typename',
+        );
+
+        return hasTotalItems && !hasItems && !hasOtherFields;
+    }
+
     @ResolveField()
     async breadcrumbs(
         @Ctx() ctx: RequestContext,

+ 49 - 36
packages/core/src/service/services/collection.service.ts

@@ -396,8 +396,7 @@ export class CollectionService implements OnModuleInit {
 
     /**
      * @description
-     * Gets the ancestors of a given collection. Note that since ProductCategories are implemented as an adjacency list, this method
-     * will produce more queries the deeper the collection is in the tree.
+     * Gets the ancestors of a given collection using a single recursive CTE query for optimal performance.
      */
     getAncestors(collectionId: ID): Promise<Collection[]>;
     getAncestors(collectionId: ID, ctx: RequestContext): Promise<Array<Translated<Collection>>>;
@@ -405,42 +404,56 @@ export class CollectionService implements OnModuleInit {
         collectionId: ID,
         ctx?: RequestContext,
     ): Promise<Array<Translated<Collection> | Collection>> {
-        const getParent = async (id: ID, _ancestors: Collection[] = []): Promise<Collection[]> => {
-            const parent = await this.connection
-                .getRepository(ctx, Collection)
-                .createQueryBuilder()
-                .relation(Collection, 'parent')
-                .of(id)
-                .loadOne();
-            if (parent) {
-                if (!parent.isRoot) {
-                    if (idsAreEqual(parent.id, id)) {
-                        Logger.error(
-                            `Circular reference detected in Collection tree: Collection ${id} is its own parent`,
-                        );
-                        return _ancestors;
-                    }
-                    _ancestors.push(parent);
-                    return getParent(parent.id, _ancestors);
-                }
-            }
-            return _ancestors;
-        };
-        const ancestors = await getParent(collectionId);
+        // Use PostgreSQL recursive CTE to fetch all ancestors in a single query
+        const query = `
+            WITH RECURSIVE collection_ancestors AS (
+                -- Base case: get the immediate parent (depth 1)
+                SELECT c.id, c."parentId", c."isRoot", 1 as depth
+                FROM collection c
+                INNER JOIN collection child ON child."parentId" = c.id
+                WHERE child.id = $1 AND c."isRoot" = false
+
+                UNION
+
+                -- Recursive case: get parent of current ancestor
+                SELECT c.id, c."parentId", c."isRoot", ca.depth + 1
+                FROM collection c
+                INNER JOIN collection_ancestors ca ON ca."parentId" = c.id
+                WHERE c."isRoot" = false
+            )
+            SELECT id, depth FROM collection_ancestors
+            ORDER BY depth ASC;
+        `;
 
-        return this.connection
+        const ancestorRows = await this.connection
             .getRepository(ctx, Collection)
-            .find({ where: { id: In(ancestors.map(c => c.id)) } })
-            .then(categories => {
-                const resultCategories: Array<Collection | Translated<Collection>> = [];
-                ancestors.forEach(a => {
-                    const category = categories.find(c => c.id === a.id);
-                    if (category) {
-                        resultCategories.push(ctx ? this.translator.translate(category, ctx) : category);
-                    }
-                });
-                return resultCategories;
-            });
+            .query(query, [collectionId]);
+
+        if (ancestorRows.length === 0) {
+            return [];
+        }
+
+        // Get IDs in order: [parent, grandparent, great-grandparent, ...]
+        const ids = ancestorRows.map((row: { id: ID; depth: number }) => row.id);
+
+        // Fetch full collection data with translations
+        const ancestors = await this.connection
+            .getRepository(ctx, Collection)
+            .createQueryBuilder('collection')
+            .leftJoinAndSelect('collection.translations', 'translations')
+            .where('collection.id IN (:...ids)', { ids })
+            .getMany();
+
+        // Preserve order: closest parent first (depth 1), then grandparent (depth 2), etc.
+        const orderedAncestors: Array<Collection | Translated<Collection>> = [];
+        for (const id of ids) {
+            const ancestor = ancestors.find(a => idsAreEqual(a.id, id));
+            if (ancestor) {
+                orderedAncestors.push(ctx ? this.translator.translate(ancestor, ctx) : ancestor);
+            }
+        }
+
+        return orderedAncestors;
     }
 
     async previewCollectionVariants(

+ 16 - 0
packages/core/src/service/services/product-variant.service.ts

@@ -237,6 +237,22 @@ export class ProductVariantService {
         });
     }
 
+    /**
+     * @description
+     * Returns the count of ProductVariants associated with the given Collection.
+     */
+    async getVariantCountByCollectionId(ctx: RequestContext, collectionId: ID): Promise<number> {
+        return await this.connection
+            .getRepository(ctx, ProductVariant)
+            .createQueryBuilder('productvariant')
+            .leftJoin('productvariant.collections', 'collection')
+            .leftJoin('productvariant.product', 'product')
+            .where('product.deletedAt IS NULL')
+            .andWhere('productvariant.deletedAt IS NULL')
+            .andWhere('collection.id = :collectionId', { collectionId })
+            .getCount();
+    }
+
     /**
      * @description
      * Returns all Channels to which the ProductVariant is assigned.

+ 4 - 0
packages/dev-server/dev-config.ts

@@ -190,6 +190,10 @@ function getDbConfig(): DataSourceOptions {
                 password: process.env.DB_PASSWORD || 'password',
                 database: process.env.DB_NAME || 'vendure-dev',
                 schema: process.env.DB_SCHEMA || 'public',
+                extra: {
+                    max: 50, // Maximum pool size
+                    min: 10, // Minimum pool size
+                },
             };
         case 'sqlite':
             console.log('Using sqlite connection');

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/dev-server/graphql/graphql-env.d.ts


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio