Browse Source

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

Leftovers.today 6 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) {
 export async function testNoGrouping(client: SimpleGraphQLClient) {
@@ -46,10 +59,11 @@ export async function testNoGrouping(client: SimpleGraphQLClient) {
         {
         {
             input: {
             input: {
                 groupByProduct: false,
                 groupByProduct: false,
+                groupBySKU: false,
             },
             },
         },
         },
     );
     );
-    expect(result.search.totalItems).toBe(34);
+    expect(result.search.totalItems).toBe(35);
 }
 }
 
 
 export async function testMatchSearchTerm(client: SimpleGraphQLClient) {
 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([
     expect(result.search.items.map(i => i.productName)).toEqual([
         'Bonsai Tree',
         'Bonsai Tree',
+        'Bonsai Tree (Ch2)',
         'Camera Lens',
         'Camera Lens',
         'Clacky Keyboard',
         'Clacky Keyboard',
         'Curvy Monitor',
         'Curvy Monitor',
@@ -157,6 +172,7 @@ export async function testMatchFacetValueFiltersOr(client: SimpleGraphQLClient)
     expect(result.search.items.map(i => i.productName).sort()).toEqual(
     expect(result.search.items.map(i => i.productName).sort()).toEqual(
         [
         [
             'Bonsai Tree',
             'Bonsai Tree',
+            'Bonsai Tree (Ch2)',
             'Camera Lens',
             'Camera Lens',
             'Clacky Keyboard',
             'Clacky Keyboard',
             'Curvy Monitor',
             'Curvy Monitor',
@@ -256,6 +272,7 @@ export async function testMatchCollectionId(client: SimpleGraphQLClient) {
     );
     );
     expect(result.search.items.map(i => i.productName).sort()).toEqual([
     expect(result.search.items.map(i => i.productName).sort()).toEqual([
         'Bonsai Tree',
         'Bonsai Tree',
+        'Bonsai Tree (Ch2)',
         'Orchid',
         'Orchid',
         'Spiky Cactus',
         'Spiky Cactus',
     ]);
     ]);
@@ -273,6 +290,7 @@ export async function testMatchCollectionSlug(client: SimpleGraphQLClient) {
     );
     );
     expect(result.search.items.map(i => i.productName).sort()).toEqual([
     expect(result.search.items.map(i => i.productName).sort()).toEqual([
         'Bonsai Tree',
         'Bonsai Tree',
+        'Bonsai Tree (Ch2)',
         'Orchid',
         'Orchid',
         'Spiky Cactus',
         '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 () => {
     it('with search term', async () => {
@@ -79,6 +91,19 @@ describe('Elasticsearch plugin with UuidIdStrategy', () => {
         expect(search.totalItems).toBe(1);
         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 () => {
     it('with collectionId filter term', async () => {
         const { collections } = await shopClient.query<GetCollectionListQuery>(GET_COLLECTION_LIST);
         const { collections } = await shopClient.query<GetCollectionListQuery>(GET_COLLECTION_LIST);
         const { search } = await shopClient.query<SearchProductsShopQuery, SearchProductsShopQueryVariables>(
         const { search } = await shopClient.query<SearchProductsShopQuery, SearchProductsShopQueryVariables>(

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

@@ -50,6 +50,7 @@ import {
     doAdminSearchQuery,
     doAdminSearchQuery,
     dropElasticIndices,
     dropElasticIndices,
     testGroupByProduct,
     testGroupByProduct,
+    testGroupBySKU,
     testMatchCollectionId,
     testMatchCollectionId,
     testMatchCollectionSlug,
     testMatchCollectionSlug,
     testMatchFacetIdsAnd,
     testMatchFacetIdsAnd,
@@ -203,6 +204,8 @@ describe('Elasticsearch plugin', () => {
     describe('shop api', () => {
     describe('shop api', () => {
         it('group by product', () => testGroupByProduct(shopClient));
         it('group by product', () => testGroupByProduct(shopClient));
 
 
+        it('group by SKU', () => testGroupBySKU(shopClient));
+
         it('no grouping', () => testNoGrouping(shopClient));
         it('no grouping', () => testNoGrouping(shopClient));
 
 
         it('matches search term', () => testMatchSearchTerm(shopClient));
         it('matches search term', () => testMatchSearchTerm(shopClient));
@@ -245,8 +248,8 @@ describe('Elasticsearch plugin', () => {
                 { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 10, facetValue: { id: 'T_4', name: 'sports equipment' } },
                 { 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: 6, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
                 { 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: 6, facetValue: { id: 'T_2', name: 'computers' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
                 { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
                 { 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([
             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([
             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 () => {
         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 () => {
         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 () => {
         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', () => {
     describe('admin api', () => {
         it('group by product', () => testGroupByProduct(adminClient));
         it('group by product', () => testGroupByProduct(adminClient));
 
 
+        it('group by SKU', () => testGroupBySKU(adminClient));
+
         it('no grouping', () => testNoGrouping(adminClient));
         it('no grouping', () => testNoGrouping(adminClient));
 
 
         it('matches search term', () => testMatchSearchTerm(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          ,               ,
 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          ,               ,
 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        , 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
             priceRange: PriceRangeInput
             priceRangeWithTax: PriceRangeInput
             priceRangeWithTax: PriceRangeInput
             inStock: Boolean
             inStock: Boolean
+            groupBySKU: Boolean
             ${inputExtensions.map(([name, type]) => `${name}: ${type}`).join('\n            ')}
             ${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,
         collectionId,
         collectionSlug,
         collectionSlug,
         groupByProduct,
         groupByProduct,
+        groupBySKU,
         skip,
         skip,
         take,
         take,
         sort,
         sort,
@@ -147,6 +148,9 @@ export function buildElasticBody(
     if (groupByProduct) {
     if (groupByProduct) {
         body.collapse = { field: 'productId' };
         body.collapse = { field: 'productId' };
     }
     }
+    if (groupBySKU) {
+        body.collapse = { field: 'sku.keyword' };
+    }
     return body;
     return body;
 }
 }
 
 

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

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

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

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