Skip to content

Commit

Permalink
feat(default-search-plugin): add support for 'currencyCode' index
Browse files Browse the repository at this point in the history
  • Loading branch information
casperiv0 committed Dec 10, 2024
1 parent 9d7e367 commit 0fc160f
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -109,6 +109,9 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap, OnApplicatio
if (options.indexStockStatus === true) {
this.addStockColumnsToEntity();
}
if (options.indexCurrencyCode) {
this.addCurrencyCodeToEntity();
}
return DefaultSearchPlugin;
}

Expand Down Expand Up @@ -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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,6 @@ export class SearchIndexItem {
inStock?: boolean;
// Added dynamically based on the `indexStockStatus` init option.
productInStock?: boolean;
// Added dynamically based TODO
currencyCode?: CurrencyCode;
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export class IndexerController {
});

if (variants) {
Logger.verbose(`Updating ${variants.length} variants`, workerLoggerCtx);
Logger.info(`Updating ${variants.length} variants`, workerLoggerCtx);
await this.saveVariants(ctx, variants);
}
return true;
Expand Down Expand Up @@ -400,8 +400,6 @@ export class IndexerController {
}

private async saveVariants(ctx: MutableRequestContext, variants: ProductVariant[]) {
const items: SearchIndexItem[] = [];

await this.removeSyntheticVariants(ctx, variants);
const productMap = new Map<ID, Product>();

Expand All @@ -417,93 +415,116 @@ export class IndexerController {
productMap.set(variant.productId, product);
}
const availableLanguageCodes = unique(ctx.channel.availableLanguageCodes);
for (const languageCode of availableLanguageCodes) {
const productTranslation = this.getTranslation(product, languageCode);
const variantTranslation = this.getTranslation(variant, languageCode);
const collectionTranslations = variant.collections.map(c =>
this.getTranslation(c, languageCode),
);
let channelIds = variant.channels.map(x => x.id);
const clone = new ProductVariant({ id: variant.id });
await this.entityHydrator.hydrate(ctx, clone, {
relations: ['channels', 'channels.defaultTaxZone'],
});
channelIds.push(
...clone.channels
.filter(x => x.availableLanguageCodes.includes(languageCode))
.map(x => x.id),
);
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) ?? [],
const availableCurrencyCodes = this.options.indexCurrencyCode
? unique(ctx.channel.availableCurrencyCodes)
: [ctx.channel.defaultCurrencyCode];

const items: SearchIndexItem[] = [];

for (const currencyCode of availableCurrencyCodes) {
for (const languageCode of availableLanguageCodes) {
const productTranslation = this.getTranslation(product, languageCode);
const variantTranslation = this.getTranslation(variant, languageCode);
const collectionTranslations = variant.collections.map(c =>
this.getTranslation(c, languageCode),
);
let channelIds = variant.channels.map(x => x.id);
const clone = new ProductVariant({ id: variant.id });
await this.entityHydrator.hydrate(ctx, clone, {
relations: ['channels', 'channels.defaultTaxZone'],
});
if (this.options.indexStockStatus) {
item.inStock =
0 < (await this.productVariantService.getSaleableStockLevel(ctx, variant));
const productInStock = await this.requestContextCache.get(
channelIds.push(
...clone.channels
.filter(x => x.availableLanguageCodes.includes(languageCode))
.map(x => x.id),
);
channelIds = unique(channelIds);

for (const channel of variant.channels) {
const ch = new Channel({ ...channel, defaultCurrencyCode: currencyCode });
ctx.setChannel(ch);

const mutatedVariant = await this.productPriceApplicator.applyChannelPriceAndTax(
variant,
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),
),
),
)
.then(stockLevels => stockLevels.some(stockLevel => 0 < stockLevel)),
);
item.productInStock = productInStock;

const item = new SearchIndexItem({
channelId: ctx.channelId,
languageCode,
currencyCode,
productVariantId: mutatedVariant.id,
price: mutatedVariant.price,
priceWithTax: mutatedVariant.priceWithTax,
sku: mutatedVariant.sku,
enabled: product.enabled === false ? false : mutatedVariant.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: mutatedVariant.featuredAsset
? mutatedVariant.featuredAsset.focalPoint
: null,
productVariantAssetId: mutatedVariant.featuredAsset
? mutatedVariant.featuredAsset.id
: null,
productPreview: product.featuredAsset ? product.featuredAsset.preview : '',
productVariantPreview: mutatedVariant.featuredAsset
? mutatedVariant.featuredAsset.preview
: '',
channelIds: channelIds.map(x => x.toString()),
facetIds: this.getFacetIds(variant, product),
facetValueIds: this.getFacetValueIds(variant, product),
collectionIds: mutatedVariant.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-${mutatedVariant.productId}`,
() =>
this.connection
.getRepository(ctx, ProductVariant)
.find({
loadEagerRelations: false,
where: {
productId: mutatedVariant.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;
}

items.push(item);
}
items.push(item);
}
}
}
ctx.setChannel(originalChannel);

await this.queue.push(() =>
this.connection.getRepository(ctx, SearchIndexItem).save(items, { chunk: 2500 }),
);
// Save in batches per currency code to avoid JS closure issues
ctx.setChannel(originalChannel);

await this.queue.push(() => {
this.connection.rawConnection.getRepository(SearchIndexItem).save(items, { chunk: 2500 });
console.log(JSON.stringify(items, null, 4));
});
}
}

/**
Expand All @@ -514,6 +535,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ export class MutableRequestContext extends RequestContext {
}
private mutatedChannel: Channel | undefined;

setChannel(channel: Channel) {
setChannel(channel: Channel, currencyCode?: CurrencyCode) {
this.mutatedChannel = channel;
if (currencyCode) {
this.mutatedChannel.defaultCurrencyCode = currencyCode;
}
}

get channel(): Channel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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');
}
Expand All @@ -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}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 9 additions & 0 deletions packages/core/src/plugin/default-search-plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ 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.
*
* @default false.
*/
indexCurrencyCode?: boolean;
/**
* @description
* If set to `true`, updates to Products, ProductVariants and Collections will not immediately
Expand Down

0 comments on commit 0fc160f

Please sign in to comment.