1
0
Эх сурвалжийг харах

feat(core): Implement caching for FacetValueChecker

Relates to #3043

BREAKING CHANGE: If you are using the `FacetValueChecker` utility class, you should
update your code to get it via the `Injector` rather than directly instantiating it.

Existing code _will_ still work without changes, but by updating you will see improved
performance due to new caching techniques.

```diff
- facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
+ facetValueChecker = injector.get(FacetValueChecker);
```
Michael Bromley 1 жил өмнө
parent
commit
3603b116ec

+ 2 - 0
packages/core/src/config/config.module.ts

@@ -110,6 +110,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
         const { healthChecks, errorHandlers } = this.configService.systemOptions;
         const { assetImportStrategy } = this.configService.importExportOptions;
         const { refundProcess: refundProcess } = this.configService.paymentOptions;
+        const { cacheStrategy } = this.configService.systemOptions;
         const entityIdStrategy = entityIdStrategyCurrent ?? entityIdStrategyDeprecated;
         return [
             ...adminAuthenticationStrategy,
@@ -150,6 +151,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             productVariantPriceSelectionStrategy,
             guestCheckoutStrategy,
             ...refundProcess,
+            cacheStrategy,
         ];
     }
 

+ 2 - 3
packages/core/src/config/promotion/actions/facet-values-percentage-discount-action.ts

@@ -1,8 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
-import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { FacetValueChecker } from '../../../service/helpers/facet-value-checker/facet-value-checker';
 import { PromotionItemAction } from '../promotion-action';
-import { FacetValueChecker } from '../utils/facet-value-checker';
 
 let facetValueChecker: FacetValueChecker;
 
@@ -23,7 +22,7 @@ export const discountOnItemWithFacets = new PromotionItemAction({
         },
     },
     init(injector) {
-        facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
+        facetValueChecker = injector.get(FacetValueChecker);
     },
     async execute(ctx, orderLine, args) {
         if (await facetValueChecker.hasFacetValues(orderLine, args.facets, ctx)) {

+ 2 - 3
packages/core/src/config/promotion/conditions/has-facet-values-condition.ts

@@ -1,8 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
-import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { FacetValueChecker } from '../../../service/helpers/facet-value-checker/facet-value-checker';
 import { PromotionCondition } from '../promotion-condition';
-import { FacetValueChecker } from '../utils/facet-value-checker';
 
 let facetValueChecker: FacetValueChecker;
 
@@ -16,7 +15,7 @@ export const hasFacetValues = new PromotionCondition({
         facets: { type: 'ID', list: true, ui: { component: 'facet-value-form-input' } },
     },
     init(injector) {
-        facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
+        facetValueChecker = injector.get(FacetValueChecker);
     },
     // eslint-disable-next-line no-shadow,@typescript-eslint/no-shadow
     async check(ctx, order, args) {

+ 0 - 1
packages/core/src/config/promotion/index.ts

@@ -24,7 +24,6 @@ export * from './conditions/min-order-amount-condition';
 export * from './conditions/contains-products-condition';
 export * from './conditions/customer-group-condition';
 export * from './conditions/buy-x-get-y-free-condition';
-export * from './utils/facet-value-checker';
 
 export const defaultPromotionActions = [
     orderFixedDiscount,

+ 0 - 79
packages/core/src/config/promotion/utils/facet-value-checker.ts

@@ -1,79 +0,0 @@
-import { ID } from '@vendure/common/lib/shared-types';
-import { unique } from '@vendure/common/lib/unique';
-
-import { RequestContext } from '../../../api';
-import { TtlCache } from '../../../common/ttl-cache';
-import { idsAreEqual } from '../../../common/utils';
-import { TransactionalConnection } from '../../../connection/transactional-connection';
-import { OrderLine } from '../../../entity/order-line/order-line.entity';
-import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
-
-/**
- * @description
- * The FacetValueChecker is a helper class used to determine whether a given OrderLine consists
- * of ProductVariants containing the given FacetValues.
- *
- * @example
- * ```ts
- * import { FacetValueChecker, LanguageCode, PromotionCondition, TransactionalConnection } from '\@vendure/core';
- *
- * let facetValueChecker: FacetValueChecker;
- *
- * export const hasFacetValues = new PromotionCondition({
- *   code: 'at_least_n_with_facets',
- *   description: [
- *     { languageCode: LanguageCode.en, value: 'Buy at least { minimum } products with the given facets' },
- *   ],
- *   args: {
- *     minimum: { type: 'int' },
- *     facets: { type: 'ID', list: true, ui: { component: 'facet-value-form-input' } },
- *   },
- *   init(injector) {
- *     facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
- *   },
- *   async check(ctx, order, args) {
- *     let matches = 0;
- *     for (const line of order.lines) {
- *       if (await facetValueChecker.hasFacetValues(line, args.facets)) {
- *           matches += line.quantity;
- *       }
- *     }
- *     return args.minimum <= matches;
- *   },
- * });
- * ```
- *
- * @docsCategory Promotions
- */
-export class FacetValueChecker {
-    private variantCache = new TtlCache<ID, ProductVariant>({ ttl: 5000 });
-
-    constructor(private connection: TransactionalConnection) {}
-    /**
-     * @description
-     * Checks a given {@link OrderLine} against the facetValueIds and returns
-     * `true` if the associated {@link ProductVariant} & {@link Product} together
-     * have *all* the specified {@link FacetValue}s.
-     */
-    async hasFacetValues(orderLine: OrderLine, facetValueIds: ID[], ctx?: RequestContext): Promise<boolean> {
-        let variant = this.variantCache.get(orderLine.productVariant.id);
-        if (!variant) {
-            variant = await this.connection
-                .getRepository(ctx, ProductVariant)
-                .findOne({
-                    where: { id: orderLine.productVariant.id },
-                    relations: ['product', 'product.facetValues', 'facetValues'],
-                })
-                .then(result => result ?? undefined);
-            if (!variant) {
-                return false;
-            }
-            this.variantCache.set(variant.id, variant);
-        }
-        const allFacetValues = unique([...variant.facetValues, ...variant.product.facetValues], 'id');
-        return facetValueIds.reduce(
-            (result, id) => result && !!allFacetValues.find(fv => idsAreEqual(fv.id, id)),
-            true as boolean,
-        );
-    }
-}

+ 149 - 0
packages/core/src/service/helpers/facet-value-checker/facet-value-checker.ts

@@ -0,0 +1,149 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import { UpdateProductInput, UpdateProductVariantInput } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
+import ms from 'ms';
+import { filter } from 'rxjs/operators';
+
+import { RequestContext } from '../../../api/index';
+import { CacheService } from '../../../cache/cache.service';
+import { idsAreEqual } from '../../../common/utils';
+import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { OrderLine } from '../../../entity/order-line/order-line.entity';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { EventBus, ProductEvent, ProductVariantEvent } from '../../../event-bus/index';
+
+/**
+ * @description
+ * The FacetValueChecker is a helper class used to determine whether a given OrderLine consists
+ * of ProductVariants containing the given FacetValues.
+ *
+ * @example
+ * ```ts
+ * import { FacetValueChecker, LanguageCode, PromotionCondition, TransactionalConnection } from '\@vendure/core';
+ *
+ * let facetValueChecker: FacetValueChecker;
+ *
+ * export const hasFacetValues = new PromotionCondition({
+ *   code: 'at_least_n_with_facets',
+ *   description: [
+ *     { languageCode: LanguageCode.en, value: 'Buy at least { minimum } products with the given facets' },
+ *   ],
+ *   args: {
+ *     minimum: { type: 'int' },
+ *     facets: { type: 'ID', list: true, ui: { component: 'facet-value-form-input' } },
+ *   },
+ *   init(injector) {
+ *     facetValueChecker = injector.get(FacetValueChecker);
+ *   },
+ *   async check(ctx, order, args) {
+ *     let matches = 0;
+ *     for (const line of order.lines) {
+ *       if (await facetValueChecker.hasFacetValues(line, args.facets)) {
+ *           matches += line.quantity;
+ *       }
+ *     }
+ *     return args.minimum <= matches;
+ *   },
+ * });
+ * ```
+ *
+ * @docsCategory Promotions
+ */
+@Injectable()
+export class FacetValueChecker implements OnModuleInit {
+    /**
+     * @deprecated
+     * Do not directly instantiate. Use the injector to get an instance:
+     *
+     * ```ts
+     * facetValueChecker = injector.get(FacetValueChecker);
+     * ```
+     * @param connection
+     */
+    constructor(
+        private connection: TransactionalConnection,
+        private cacheService?: CacheService,
+        private eventBus?: EventBus,
+    ) {}
+
+    onModuleInit(): any {
+        this.eventBus
+            ?.ofType(ProductEvent)
+            .pipe(filter(event => event.type === 'updated'))
+            .subscribe(async event => {
+                if ((event.input as UpdateProductInput).facetValueIds) {
+                    const variantIds = await this.connection.rawConnection
+                        .getRepository(ProductVariant)
+                        .createQueryBuilder('variant')
+                        .select('variant.id', 'id')
+                        .where('variant.productId = :prodId', { prodId: event.product.id })
+                        .getRawMany()
+                        .then(result => result.map(r => r.id));
+
+                    if (variantIds.length) {
+                        await this.deleteVariantIdsFromCache(variantIds);
+                    }
+                }
+            });
+
+        this.eventBus
+            ?.ofType(ProductVariantEvent)
+            .pipe(filter(event => event.type === 'updated'))
+            .subscribe(async event => {
+                const updatedVariantIds: ID[] = [];
+                if (Array.isArray(event.input)) {
+                    for (const input of event.input) {
+                        if ((input as UpdateProductVariantInput).facetValueIds) {
+                            updatedVariantIds.push((input as UpdateProductVariantInput).id);
+                        }
+                    }
+                }
+                if (updatedVariantIds.length > 0) {
+                    await this.deleteVariantIdsFromCache(updatedVariantIds);
+                }
+            });
+    }
+
+    private deleteVariantIdsFromCache(variantIds: ID[]) {
+        return Promise.all(variantIds.map(id => this.cacheService?.delete(this.getCacheKey(id))));
+    }
+
+    /**
+     * @description
+     * Checks a given {@link OrderLine} against the facetValueIds and returns
+     * `true` if the associated {@link ProductVariant} & {@link Product} together
+     * have *all* the specified {@link FacetValue}s.
+     */
+    async hasFacetValues(orderLine: OrderLine, facetValueIds: ID[], ctx?: RequestContext): Promise<boolean> {
+        const variantId = orderLine.productVariant.id;
+        const cacheKey = this.getCacheKey(variantId);
+        let variantFacetValueIds = await this.cacheService?.get<ID[]>(cacheKey);
+        if (!variantFacetValueIds) {
+            const variant = await this.connection
+                .getRepository(ctx, ProductVariant)
+                .findOne({
+                    where: { id: orderLine.productVariant.id },
+                    relations: ['product', 'product.facetValues', 'facetValues'],
+                    loadEagerRelations: false,
+                })
+                .then(result => result ?? undefined);
+            if (!variant) {
+                variantFacetValueIds = [];
+            } else {
+                variantFacetValueIds = unique(
+                    [...variant.facetValues, ...variant.product.facetValues].map(fv => fv.id),
+                );
+            }
+            await this.cacheService?.set(cacheKey, variantFacetValueIds, { ttl: ms('1w') });
+        }
+        return facetValueIds.reduce(
+            (result, id) => result && !!(variantFacetValueIds ?? []).find(_id => idsAreEqual(_id, id)),
+            true as boolean,
+        );
+    }
+
+    private getCacheKey(variantId: ID) {
+        return `FacetValueChecker.${variantId}`;
+    }
+}

+ 1 - 0
packages/core/src/service/index.ts

@@ -4,6 +4,7 @@ export * from './helpers/custom-field-relation/custom-field-relation.service';
 export * from './helpers/entity-duplicator/entity-duplicator.service';
 export * from './helpers/entity-hydrator/entity-hydrator.service';
 export * from './helpers/external-authentication/external-authentication.service';
+export * from './helpers/facet-value-checker/facet-value-checker';
 export * from './helpers/fulfillment-state-machine/fulfillment-state';
 export * from './helpers/list-query-builder/list-query-builder';
 export * from './helpers/locale-string-hydrator/locale-string-hydrator';

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -12,6 +12,7 @@ import { CustomFieldRelationService } from './helpers/custom-field-relation/cust
 import { EntityDuplicatorService } from './helpers/entity-duplicator/entity-duplicator.service';
 import { EntityHydrator } from './helpers/entity-hydrator/entity-hydrator.service';
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
+import { FacetValueChecker } from './helpers/facet-value-checker/facet-value-checker';
 import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
 import { LocaleStringHydrator } from './helpers/locale-string-hydrator/locale-string-hydrator';
@@ -130,6 +131,7 @@ const helpers = [
     RequestContextService,
     TranslatorService,
     EntityDuplicatorService,
+    FacetValueChecker,
 ];
 
 /**