Browse Source

feat(elasticsearch-plugin): Extend ElasticSearch to also support groupBySKU for multi-vendor store scenarios (#3528)

Leftovers.today 5 months ago
parent
commit
ec1cc5e8e4

+ 20 - 2
packages/elasticsearch-plugin/e2e/e2e-helpers.ts

@@ -37,7 +37,20 @@ export async function testGroupByProduct(client: SimpleGraphQLClient) {
             },
         },
     );
-    expect(result.search.totalItems).toBe(20);
+    expect(result.search.totalItems).toBe(21);
+}
+
+export async function testGroupBySKU(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShopQuery, SearchProductsShopQueryVariables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                term: 'bonsai',
+                groupBySKU: true,
+            },
+        },
+    );
+    expect(result.search.totalItems).toBe(1);
 }
 
 export async function testNoGrouping(client: SimpleGraphQLClient) {
@@ -46,10 +59,11 @@ export async function testNoGrouping(client: SimpleGraphQLClient) {
         {
             input: {
                 groupByProduct: false,
+                groupBySKU: false,
             },
         },
     );
-    expect(result.search.totalItems).toBe(34);
+    expect(result.search.totalItems).toBe(35);
 }
 
 export async function testMatchSearchTerm(client: SimpleGraphQLClient) {
@@ -110,6 +124,7 @@ export async function testMatchFacetIdsOr(client: SimpleGraphQLClient) {
     );
     expect(result.search.items.map(i => i.productName)).toEqual([
         'Bonsai Tree',
+        'Bonsai Tree (Ch2)',
         'Camera Lens',
         'Clacky Keyboard',
         'Curvy Monitor',
@@ -157,6 +172,7 @@ export async function testMatchFacetValueFiltersOr(client: SimpleGraphQLClient)
     expect(result.search.items.map(i => i.productName).sort()).toEqual(
         [
             'Bonsai Tree',
+            'Bonsai Tree (Ch2)',
             'Camera Lens',
             'Clacky Keyboard',
             'Curvy Monitor',
@@ -256,6 +272,7 @@ export async function testMatchCollectionId(client: SimpleGraphQLClient) {
     );
     expect(result.search.items.map(i => i.productName).sort()).toEqual([
         'Bonsai Tree',
+        'Bonsai Tree (Ch2)',
         'Orchid',
         'Spiky Cactus',
     ]);
@@ -273,6 +290,7 @@ export async function testMatchCollectionSlug(client: SimpleGraphQLClient) {
     );
     expect(result.search.items.map(i => i.productName).sort()).toEqual([
         'Bonsai Tree',
+        'Bonsai Tree (Ch2)',
         'Orchid',
         'Spiky Cactus',
     ]);

+ 26 - 1
packages/elasticsearch-plugin/e2e/elasticsearch-plugin-uuid.e2e-spec.ts

@@ -63,7 +63,19 @@ describe('Elasticsearch plugin with UuidIdStrategy', () => {
                 },
             },
         );
-        expect(search.totalItems).toBe(20);
+        expect(search.totalItems).toBe(21);
+    });
+
+    it('no term or filters grouped by SKU', async () => {
+        const { search } = await shopClient.query<SearchProductsShopQuery, SearchProductsShopQueryVariables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    groupBySKU: true,
+                },
+            },
+        );
+        expect(search.totalItems).toBe(34);
     });
 
     it('with search term', async () => {
@@ -79,6 +91,19 @@ describe('Elasticsearch plugin with UuidIdStrategy', () => {
         expect(search.totalItems).toBe(1);
     });
 
+        it('with search term grouped by SKU', async () => {
+        const { search } = await shopClient.query<SearchProductsShopQuery, SearchProductsShopQueryVariables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    groupBySKU: true,
+                    term: 'bonsai',
+                },
+            },
+        );
+        expect(search.totalItems).toBe(1);
+    });
+
     it('with collectionId filter term', async () => {
         const { collections } = await shopClient.query<GetCollectionListQuery>(GET_COLLECTION_LIST);
         const { search } = await shopClient.query<SearchProductsShopQuery, SearchProductsShopQueryVariables>(

+ 17 - 12
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -50,6 +50,7 @@ import {
     doAdminSearchQuery,
     dropElasticIndices,
     testGroupByProduct,
+    testGroupBySKU,
     testMatchCollectionId,
     testMatchCollectionSlug,
     testMatchFacetIdsAnd,
@@ -203,6 +204,8 @@ describe('Elasticsearch plugin', () => {
     describe('shop api', () => {
         it('group by product', () => testGroupByProduct(shopClient));
 
+        it('group by SKU', () => testGroupBySKU(shopClient));
+
         it('no grouping', () => testNoGrouping(shopClient));
 
         it('matches search term', () => testMatchSearchTerm(shopClient));
@@ -245,8 +248,8 @@ describe('Elasticsearch plugin', () => {
                 { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 10, facetValue: { id: 'T_4', name: 'sports equipment' } },
-                { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
-                { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
+                { count: 4, facetValue: { id: 'T_5', name: 'home & garden' } },
+                { count: 4, facetValue: { id: 'T_6', name: 'plants' } },
             ]);
         });
 
@@ -264,8 +267,8 @@ describe('Elasticsearch plugin', () => {
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
-                { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
-                { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
+                { count: 4, facetValue: { id: 'T_5', name: 'home & garden' } },
+                { count: 4, facetValue: { id: 'T_6', name: 'plants' } },
             ]);
         });
 
@@ -312,8 +315,8 @@ describe('Elasticsearch plugin', () => {
                 { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
-                { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
-                { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
+                { count: 4, facetValue: { id: 'T_5', name: 'home & garden' } },
+                { count: 4, facetValue: { id: 'T_6', name: 'plants' } },
             ]);
         });
 
@@ -327,7 +330,7 @@ describe('Elasticsearch plugin', () => {
                 },
             });
             expect(result.search.collections).toEqual([
-                { collection: { id: 'T_2', name: 'Plants' }, count: 3 },
+                { collection: { id: 'T_2', name: 'Plants' }, count: 4 },
             ]);
         });
 
@@ -341,7 +344,7 @@ describe('Elasticsearch plugin', () => {
                 },
             });
             expect(result.search.collections).toEqual([
-                { collection: { id: 'T_2', name: 'Plants' }, count: 3 },
+                { collection: { id: 'T_2', name: 'Plants' }, count: 4 },
             ]);
         });
 
@@ -407,7 +410,7 @@ describe('Elasticsearch plugin', () => {
                     },
                 },
             );
-            expect(result.search.totalItems).toBe(2);
+            expect(result.search.totalItems).toBe(3);
         });
 
         it('inStock is false and grouped by product', async () => {
@@ -420,7 +423,7 @@ describe('Elasticsearch plugin', () => {
                     },
                 },
             );
-            expect(result.search.totalItems).toBe(1);
+            expect(result.search.totalItems).toBe(2);
         });
 
         it('inStock is true and not grouped by product', async () => {
@@ -459,7 +462,7 @@ describe('Elasticsearch plugin', () => {
                     },
                 },
             );
-            expect(result.search.totalItems).toBe(33);
+            expect(result.search.totalItems).toBe(34);
         });
 
         it('inStock is undefined and grouped by product', async () => {
@@ -472,13 +475,15 @@ describe('Elasticsearch plugin', () => {
                     },
                 },
             );
-            expect(result.search.totalItems).toBe(20);
+            expect(result.search.totalItems).toBe(21);
         });
     });
 
     describe('admin api', () => {
         it('group by product', () => testGroupByProduct(adminClient));
 
+        it('group by SKU', () => testGroupBySKU(adminClient));
+
         it('no grouping', () => testNoGrouping(adminClient));
 
         it('matches search term', () => testMatchSearchTerm(adminClient));

+ 1 - 0
packages/elasticsearch-plugin/e2e/fixtures/e2e-products-full.csv

@@ -33,3 +33,4 @@ Running Shoe       , running-shoe       , "With its ultra-light, uber-responsive
 Spiky Cactus       , spiky-cactus       , "A spiky yet elegant house cactus - perfect for the home or office. Origin and habitat: Probably native only to the Andes of Peru"                                                                                                                                                                                                                                                                                                                                                            ,                                    , category:home & garden|category:plants  ,                   ,                     , SC011001     , 15.50   , standard    , 100         , true          ,               ,
 Orchid             , orchid             , "Gloriously elegant. It can go along with any interior as it is a neutral color and the most popular Phalaenopsis overall. 2 to 3 foot stems host large white flowers that can last for over 2 months."                                                                                                                                                                                                                                                                                       ,                                    , category:home & garden|category:plants  ,                   ,                     , ROR00221     , 65.00   , standard    , 100         , true          ,               ,
 Bonsai Tree        , bonsai-tree        , "Excellent semi-evergreen bonsai. Indoors or out but needs some winter protection. All trees sent will leave the nursery in excellent condition and will be of equal quality or better than the photograph shown."                                                                                                                                                                                                                                                                            ,                                    , category:home & garden|category:plants  ,                   ,                     , B01MXFLUSV   , 19.99   , standard    , 0           , true          ,               ,
+Bonsai Tree (Ch2)  , bonsai-tree-ch2    , "SAME PRODUCT IN A DIFFERENT CHANNEL WITH SAME SKU to test groupBySKU - Excellent semi-evergreen bonsai. Indoors or out but needs some winter protection. All trees sent will leave the nursery in excellent condition and will be of equal quality or better than the photograph shown."                                                                                                                                                                                                     ,                                    , category:home & garden|category:plants  ,                   ,                     , B01MXFLUSV   , 19.99   , standard    , 0           , true          ,               ,

+ 1 - 0
packages/elasticsearch-plugin/src/api/api-extensions.ts

@@ -38,6 +38,7 @@ export function generateSchemaExtensions(options: ElasticsearchOptions): Documen
             priceRange: PriceRangeInput
             priceRangeWithTax: PriceRangeInput
             inStock: Boolean
+            groupBySKU: Boolean
             ${inputExtensions.map(([name, type]) => `${name}: ${type}`).join('\n            ')}
         }
 

+ 4 - 0
packages/elasticsearch-plugin/src/build-elastic-body.ts

@@ -22,6 +22,7 @@ export function buildElasticBody(
         collectionId,
         collectionSlug,
         groupByProduct,
+        groupBySKU,
         skip,
         take,
         sort,
@@ -147,6 +148,9 @@ export function buildElasticBody(
     if (groupByProduct) {
         body.collapse = { field: 'productId' };
     }
+    if (groupBySKU) {
+        body.collapse = { field: 'sku.keyword' };
+    }
     return body;
 }
 

+ 42 - 19
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -182,7 +182,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         enabledOnly: boolean = false,
     ): Promise<Omit<ElasticSearchResponse, 'facetValues' | 'collections' | 'priceRange'>> {
         const { indexPrefix } = this.options;
-        const { groupByProduct } = input;
+        const { groupByProduct, groupBySKU } = input;
         const elasticSearchBody = buildElasticBody(
             input,
             this.options.searchConfig,
@@ -191,16 +191,25 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             enabledOnly,
             ctx,
         );
-        if (groupByProduct) {
+
+        if (groupByProduct && groupBySKU) {
+            throw new InternalServerError(
+                'Cannot use both groupByProduct and groupBySKU simultaneously. Please set only one of these options to true.',
+            );
+        }
+
+        if (groupByProduct || groupBySKU) {
             try {
                 const { body }: { body: SearchResponseBody<VariantIndexItem> } = await this.client.search({
                     index: indexPrefix + VARIANT_INDEX_NAME,
                     body: elasticSearchBody,
                 });
-                const totalItems = await this.totalHits(ctx, input, groupByProduct);
+
+                const totalItems = await this.totalHits(ctx, input, enabledOnly);
+
                 await this.eventBus.publish(new SearchEvent(ctx, input));
                 return {
-                    items: body.hits.hits.map(hit => this.mapProductToSearchResult(hit)),
+                    items: body.hits.hits.map(hit => this.mapProductToSearchResult(hit, groupByProduct, groupBySKU)),
                     totalItems,
                 };
             } catch (e: any) {
@@ -251,6 +260,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         enabledOnly: boolean = false,
     ): Promise<number> {
         const { indexPrefix, searchConfig } = this.options;
+        const { groupBySKU } = input;
         const elasticSearchBody = buildElasticBody(
             input,
             searchConfig,
@@ -262,12 +272,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         elasticSearchBody.from = 0;
         elasticSearchBody.size = 0;
         elasticSearchBody.aggs = {
-            total: {
-                cardinality: {
-                    field: 'productId',
+                total: {
+                    cardinality: {
+                        field: groupBySKU ? 'sku.keyword' : 'productId',
+                    },
                 },
-            },
-        };
+            };
         const { body }: { body: SearchResponseBody<VariantIndexItem> } = await this.client.search({
             index: indexPrefix + VARIANT_INDEX_NAME,
             body: elasticSearchBody,
@@ -290,7 +300,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         input: ElasticSearchInput,
         enabledOnly: boolean = false,
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
-        const { groupByProduct } = input;
+        const { groupByProduct, groupBySKU } = input;
         const buckets = await this.getDistinctBucketsOfField(
             ctx,
             input,
@@ -306,7 +316,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         return facetValues.map(facetValue => {
             const bucket = buckets.find(b => b.key.toString() === facetValue.id.toString());
             let count;
-            if (groupByProduct) {
+            if (groupByProduct || groupBySKU) {
                 count = bucket ? bucket.total.value : 0;
             } else {
                 count = bucket ? bucket.doc_count : 0;
@@ -326,7 +336,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         input: ElasticSearchInput,
         enabledOnly: boolean = false,
     ): Promise<Array<{ collection: Collection; count: number }>> {
-        const { groupByProduct } = input;
+        const { groupByProduct, groupBySKU } = input;
         const buckets = await this.getDistinctBucketsOfField(
             ctx,
             input,
@@ -342,7 +352,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         return collections.map(collection => {
             const bucket = buckets.find(b => b.key.toString() === collection.id.toString());
             let count;
-            if (groupByProduct) {
+            if (groupByProduct || groupBySKU) {
                 count = bucket ? bucket.total.value : 0;
             } else {
                 count = bucket ? bucket.doc_count : 0;
@@ -362,7 +372,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         aggregation_max_size: number,
     ): Promise<Array<{ key: string; doc_count: number; total: { value: number } }>> {
         const { indexPrefix } = this.options;
-        const { groupByProduct } = input;
+        const { groupByProduct, groupBySKU } = input;
         const elasticSearchBody = buildElasticBody(
             input,
             this.options.searchConfig,
@@ -392,6 +402,16 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             };
         }
 
+        if (groupBySKU) {
+            elasticSearchBody.aggs.aggregation_field.aggs = {
+                total: {
+                    cardinality: {
+                        field: 'sku.keyword',
+                    },
+                },
+            };
+        }
+
         let body: SearchResponseBody<VariantIndexItem>;
         try {
             const result = await this.client.search<SearchResponseBody<VariantIndexItem>>({
@@ -515,6 +535,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             this.options.customProductMappings,
             this.options.customProductVariantMappings,
             false,
+            false,
         );
         ElasticsearchService.addScriptMappings(
             result,
@@ -523,9 +544,9 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             'variant',
         );
         return result;
-    }
+    } 
 
-    private mapProductToSearchResult(hit: SearchHit<VariantIndexItem>): ElasticSearchResult {
+    private mapProductToSearchResult(hit: SearchHit<VariantIndexItem>, groupByProduct: boolean = false, groupBySKU: boolean = false): ElasticSearchResult {
         const source = hit._source;
         const fields = hit.fields;
         const { productAsset, productVariantAsset } = this.getSearchResultAssets(source);
@@ -560,7 +581,8 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             source,
             this.options.customProductMappings,
             this.options.customProductVariantMappings,
-            true,
+            groupByProduct,
+            groupBySKU,
         );
         ElasticsearchService.addScriptMappings(
             result,
@@ -598,6 +620,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         productMappings: { [fieldName: string]: CustomMapping<any> },
         variantMappings: { [fieldName: string]: CustomMapping<any> },
         groupByProduct: boolean,
+        groupBySKU: boolean,
     ): any {
         const productCustomMappings = Object.keys(productMappings);
         if (productCustomMappings.length) {
@@ -606,7 +629,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
                 customMappingsResult[name] = source[`product-${name}`];
             }
             result.customProductMappings = customMappingsResult;
-            if (groupByProduct) {
+            if (groupByProduct || groupBySKU) {
                 result.customMappings = customMappingsResult;
             }
         }
@@ -617,7 +640,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
                 customMappingsResult[name] = source[`variant-${name}`];
             }
             result.customProductVariantMappings = customMappingsResult;
-            if (!groupByProduct) {
+            if (!groupByProduct && !groupBySKU) {
                 result.customMappings = customMappingsResult;
             }
         }

+ 4 - 4
packages/elasticsearch-plugin/src/options.ts

@@ -192,8 +192,8 @@ export interface ElasticsearchOptions {
      * If this property is set to `false` it's not accessible in the `customMappings` field but it's still getting
      * parsed to the elasticsearch index.
      *
-     * This config option defines custom mappings which are accessible when the "groupByProduct"
-     * input options is set to `true`. In addition, custom variant mappings can be accessed by using
+     * This config option defines custom mappings which are accessible when the "groupByProduct" or "groupBySKU"
+     * input options is set to `true` (Do not set both to true at the same time). In addition, custom variant mappings can be accessed by using
      * the `customProductVariantMappings` field, which is always available.
      *
      * @example
@@ -246,8 +246,8 @@ export interface ElasticsearchOptions {
     };
     /**
      * @description
-     * This config option defines custom mappings which are accessible when the "groupByProduct"
-     * input options is set to `false`. In addition, custom product mappings can be accessed by using
+     * This config option defines custom mappings which are accessible when the "groupByProduct" and "groupBySKU"
+     * input options are both set to `false`. In addition, custom product mappings can be accessed by using
      * the `customProductMappings` field, which is always available.
      *
      * @example

+ 1 - 0
packages/elasticsearch-plugin/src/types.ts

@@ -18,6 +18,7 @@ export type ElasticSearchInput = SearchInput & {
     priceRange?: PriceRange;
     priceRangeWithTax?: PriceRange;
     inStock?: boolean;
+    groupBySKU?: boolean;
     [extendedInputField: string]: any;
 };