Skip to content

Commit

Permalink
feat(core): Implement caching for FacetValueChecker
Browse files Browse the repository at this point in the history
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);
```
  • Loading branch information
michaelbromley committed Sep 10, 2024
1 parent 489c9c0 commit 3603b11
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 86 deletions.
2 changes: 2 additions & 0 deletions packages/core/src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -150,6 +151,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
productVariantPriceSelectionStrategy,
guestCheckoutStrategy,
...refundProcess,
cacheStrategy,
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/config/promotion/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 0 additions & 79 deletions packages/core/src/config/promotion/utils/facet-value-checker.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/core/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/service/service.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -130,6 +131,7 @@ const helpers = [
RequestContextService,
TranslatorService,
EntityDuplicatorService,
FacetValueChecker,
];

/**
Expand Down

0 comments on commit 3603b11

Please sign in to comment.