Browse Source

feat(default-search-plugin): add support for 'currencyCode' index (#3268)

Casper Iversen 2 months ago
parent
commit
1af09cdb9c

+ 15 - 1
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -3,7 +3,7 @@ import { ModuleRef } from '@nestjs/core';
 import { SearchReindexResponse } from '@vendure/common/lib/generated-types';
 import { ID, Type } from '@vendure/common/lib/shared-types';
 import { buffer, debounceTime, delay, filter, map } from 'rxjs/operators';
-import { Column } from 'typeorm';
+import { Column, PrimaryColumn } from 'typeorm';
 
 import { Injector } from '../../common';
 import { idsAreEqual } from '../../common/utils';
@@ -109,6 +109,9 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap, OnApplicatio
         if (options.indexStockStatus === true) {
             this.addStockColumnsToEntity();
         }
+        if (options.indexCurrencyCode) {
+            this.addCurrencyCodeToEntity();
+        }
         return DefaultSearchPlugin;
     }
 
@@ -240,4 +243,15 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap, OnApplicatio
         Column({ type: 'boolean', default: true })(instance, 'inStock');
         Column({ type: 'boolean', default: true })(instance, 'productInStock');
     }
+
+    /**
+     * If the `indexCurrencyCode` option is set to `true`, we dynamically add
+     * a column to the SearchIndexItem entity. This is done in this way to allow us to add
+     * support for indexing on the currency code, while preventing a backwards-incompatible
+     * schema change.
+     */
+    private static addCurrencyCodeToEntity() {
+        const instance = new SearchIndexItem();
+        PrimaryColumn({ type: 'varchar' })(instance, 'currencyCode');
+    }
 }

+ 2 - 0
packages/core/src/plugin/default-search-plugin/entities/search-index-item.entity.ts

@@ -91,4 +91,6 @@ export class SearchIndexItem {
     inStock?: boolean;
     // Added dynamically based on the `indexStockStatus` init option.
     productInStock?: boolean;
+    // Added dynamically based on the `indexCurrencyCode` init option.
+    currencyCode?: CurrencyCode;
 }

+ 68 - 58
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -435,66 +435,75 @@ export class IndexerController {
                 channelIds = unique(channelIds);
 
                 for (const channel of variant.channels) {
-                    ctx.setChannel(channel);
-                    await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx);
-                    const item = new SearchIndexItem({
-                        channelId: ctx.channelId,
-                        languageCode,
-                        productVariantId: variant.id,
-                        price: variant.price,
-                        priceWithTax: variant.priceWithTax,
-                        sku: variant.sku,
-                        enabled: product.enabled === false ? false : variant.enabled,
-                        slug: productTranslation?.slug ?? '',
-                        productId: product.id,
-                        productName: productTranslation?.name ?? '',
-                        description: this.constrainDescription(productTranslation?.description ?? ''),
-                        productVariantName: variantTranslation?.name ?? '',
-                        productAssetId: product.featuredAsset ? product.featuredAsset.id : null,
-                        productPreviewFocalPoint: product.featuredAsset
-                            ? product.featuredAsset.focalPoint
-                            : null,
-                        productVariantPreviewFocalPoint: variant.featuredAsset
-                            ? variant.featuredAsset.focalPoint
-                            : null,
-                        productVariantAssetId: variant.featuredAsset ? variant.featuredAsset.id : null,
-                        productPreview: product.featuredAsset ? product.featuredAsset.preview : '',
-                        productVariantPreview: variant.featuredAsset ? variant.featuredAsset.preview : '',
-                        channelIds: channelIds.map(x => x.toString()),
-                        facetIds: this.getFacetIds(variant, product),
-                        facetValueIds: this.getFacetValueIds(variant, product),
-                        collectionIds: variant.collections.map(c => c.id.toString()),
-                        collectionSlugs:
-                            collectionTranslations.map(c => c?.slug).filter(notNullOrUndefined) ?? [],
-                    });
-                    if (this.options.indexStockStatus) {
-                        item.inStock =
-                            0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant));
-                        const productInStock = await this.requestContextCache.get(
-                            ctx,
-                            `productVariantsStock-${variant.productId}`,
-                            () =>
-                                this.connection
-                                    .getRepository(ctx, ProductVariant)
-                                    .find({
-                                        loadEagerRelations: false,
-                                        where: {
-                                            productId: variant.productId,
-                                            deletedAt: IsNull(),
-                                        },
-                                    })
-                                    .then(_variants =>
-                                        Promise.all(
-                                            _variants.map(v =>
-                                                this.productVariantService.getSaleableStockLevel(ctx, v),
+                    const availableCurrencyCodes = this.options.indexCurrencyCode
+                        ? unique(channel.availableCurrencyCodes)
+                        : [channel.defaultCurrencyCode];
+
+                    for (const currencyCode of availableCurrencyCodes) {
+                        const ch = new Channel({ ...channel, defaultCurrencyCode: currencyCode });
+                        ctx.setChannel(ch);
+
+                        await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx);
+                        const item = new SearchIndexItem({
+                            channelId: ctx.channelId,
+                            languageCode,
+                            currencyCode,
+                            productVariantId: variant.id,
+                            price: variant.price,
+                            priceWithTax: variant.priceWithTax,
+                            sku: variant.sku,
+                            enabled: product.enabled === false ? false : variant.enabled,
+                            slug: productTranslation?.slug ?? '',
+                            productId: product.id,
+                            productName: productTranslation?.name ?? '',
+                            description: this.constrainDescription(productTranslation?.description ?? ''),
+                            productVariantName: variantTranslation?.name ?? '',
+                            productAssetId: product.featuredAsset ? product.featuredAsset.id : null,
+                            productPreviewFocalPoint: product.featuredAsset
+                                ? product.featuredAsset.focalPoint
+                                : null,
+                            productVariantPreviewFocalPoint: variant.featuredAsset
+                                ? variant.featuredAsset.focalPoint
+                                : null,
+                            productVariantAssetId: variant.featuredAsset ? variant.featuredAsset.id : null,
+                            productPreview: product.featuredAsset ? product.featuredAsset.preview : '',
+                            productVariantPreview: variant.featuredAsset ? variant.featuredAsset.preview : '',
+                            channelIds: channelIds.map(x => x.toString()),
+                            facetIds: this.getFacetIds(variant, product),
+                            facetValueIds: this.getFacetValueIds(variant, product),
+                            collectionIds: variant.collections.map(c => c.id.toString()),
+                            collectionSlugs:
+                                collectionTranslations.map(c => c?.slug).filter(notNullOrUndefined) ?? [],
+                        });
+                        if (this.options.indexStockStatus) {
+                            item.inStock =
+                                0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant));
+                            const productInStock = await this.requestContextCache.get(
+                                ctx,
+                                `productVariantsStock-${variant.productId}-${ctx.channelId}`,
+                                () =>
+                                    this.connection
+                                        .getRepository(ctx, ProductVariant)
+                                        .find({
+                                            loadEagerRelations: false,
+                                            where: {
+                                                productId: variant.productId,
+                                                deletedAt: IsNull(),
+                                            },
+                                        })
+                                        .then(_variants =>
+                                            Promise.all(
+                                                _variants.map(v =>
+                                                    this.productVariantService.getSaleableStockLevel(ctx, v),
+                                                ),
                                             ),
-                                        ),
-                                    )
-                                    .then(stockLevels => stockLevels.some(stockLevel => 0 < stockLevel)),
-                        );
-                        item.productInStock = productInStock;
+                                        )
+                                        .then(stockLevels => stockLevels.some(stockLevel => 0 < stockLevel)),
+                            );
+                            item.productInStock = productInStock;
+                        }
+                        items.push(item);
                     }
-                    items.push(item);
                 }
             }
         }
@@ -513,6 +522,7 @@ export class IndexerController {
         const productTranslation = this.getTranslation(product, ctx.languageCode);
         const item = new SearchIndexItem({
             channelId: ctx.channelId,
+            currencyCode: ctx.currencyCode,
             languageCode: ctx.languageCode,
             productVariantId: 0,
             price: 0,

+ 13 - 2
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -123,7 +123,14 @@ export class MysqlSearchStrategy implements SearchStrategy {
             .limit(take)
             .offset(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode)));
+            .then(res =>
+                res.map(r =>
+                    mapToSearchResult(
+                        r,
+                        this.options.indexCurrencyCode ? r.si_currencyCode : ctx.channel.defaultCurrencyCode,
+                    ),
+                ),
+            );
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
@@ -259,6 +266,10 @@ export class MysqlSearchStrategy implements SearchStrategy {
         qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId });
         applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode);
 
+        if (this.options.indexCurrencyCode) {
+            qb.andWhere('si.currencyCode = :currencyCode', { currencyCode: ctx.currencyCode });
+        }
+
         if (input.groupByProduct === true) {
             qb.groupBy('si.productId');
             qb.addSelect('BIT_OR(si.enabled)', 'productEnabled');
@@ -272,7 +283,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
      * "MIN" function in this case to all other columns than the productId.
      */
     private createMysqlSelect(groupByProduct: boolean): string {
-        return getFieldsToSelect(this.options.indexStockStatus)
+        return getFieldsToSelect(this.options.indexStockStatus, this.options.indexCurrencyCode)
             .map(col => {
                 const qualifiedName = `si.${col}`;
                 const alias = `si_${col}`;

+ 13 - 2
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -123,7 +123,14 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .limit(take)
             .offset(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode)));
+            .then(res =>
+                res.map(r =>
+                    mapToSearchResult(
+                        r,
+                        this.options.indexCurrencyCode ? r.si_currencyCode : ctx.channel.defaultCurrencyCode,
+                    ),
+                ),
+            );
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
@@ -254,6 +261,10 @@ export class PostgresSearchStrategy implements SearchStrategy {
         qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId });
         applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode);
 
+        if (this.options.indexCurrencyCode) {
+            qb.andWhere('si.currencyCode = :currencyCode', { currencyCode: ctx.currencyCode });
+        }
+
         if (input.groupByProduct === true) {
             qb.groupBy('si.productId');
         }
@@ -267,7 +278,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
      * "MIN" function in this case to all other columns than the productId.
      */
     private createPostgresSelect(groupByProduct: boolean): string {
-        return getFieldsToSelect(this.options.indexStockStatus)
+        return getFieldsToSelect(this.options.indexStockStatus, this.options.indexCurrencyCode)
             .map(col => {
                 const qualifiedName = `si.${col}`;
                 const alias = `si_${col}`;

+ 9 - 2
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts

@@ -22,6 +22,13 @@ export const fieldsToSelect = [
     'productVariantPreviewFocalPoint',
 ];
 
-export function getFieldsToSelect(includeStockStatus: boolean = false) {
-    return includeStockStatus ? [...fieldsToSelect, 'inStock', 'productInStock'] : fieldsToSelect;
+export function getFieldsToSelect(includeStockStatus: boolean = false, includeCurrencyCode: boolean = false) {
+    const _fieldsToSelect = [...fieldsToSelect];
+    if (includeStockStatus) {
+        _fieldsToSelect.push('inStock');
+    }
+    if (includeCurrencyCode) {
+        _fieldsToSelect.push('currencyCode');
+    }
+    return _fieldsToSelect;
 }

+ 10 - 0
packages/core/src/plugin/default-search-plugin/types.ts

@@ -22,6 +22,16 @@ export interface DefaultSearchPluginInitOptions {
      * @default false.
      */
     indexStockStatus?: boolean;
+    /**
+     * @description
+     * If set to `true`, the currencyCode of the ProductVariant will be exposed in the
+     * `search` query results. Enabling this option on an existing Vendure installation
+     * will require a DB migration/synchronization.
+     *
+     * @since 3.6.0
+     * @default false.
+     */
+    indexCurrencyCode?: boolean;
     /**
      * @description
      * If set to `true`, updates to Products, ProductVariants and Collections will not immediately