Explorar o código

feat(core): Add support for stock status in DefaultSearchPlugin

Relates to #870
Michael Bromley %!s(int64=4) %!d(string=hai) anos
pai
achega
65add05d5a

+ 97 - 2
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -75,17 +75,22 @@ describe('Default search plugin', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig, {
             logger: new DefaultLogger(),
-            plugins: [DefaultSearchPlugin, DefaultJobQueuePlugin],
+            plugins: [DefaultSearchPlugin.init({ indexStockStatus: true }), DefaultJobQueuePlugin],
         }),
     );
 
     beforeAll(async () => {
         await server.init({
             initialData,
-            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-default-search.csv'),
             customerCount: 1,
         });
         await adminClient.asSuperAdmin();
+        if (testConfig.dbConnectionOptions.type === 'mysql') {
+            // Mysql seems to occasionally run into some kind of race condition
+            // relating to the populating of data, so we add a pause here.
+            await new Promise(resolve => setTimeout(resolve, 10000));
+        }
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -641,6 +646,96 @@ describe('Default search plugin', () => {
 
             expect(result.search.items[0].collectionIds).toEqual(['T_2']);
         });
+
+        it('inStock is false and not grouped by product', async () => {
+            // ToDo Remove @ts-ignore after implementing inStock in default-search-plugin
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: false,
+                        // @ts-ignore
+                        inStock: false,
+                    },
+                },
+            );
+            expect(result.search.totalItems).toBe(2);
+        });
+
+        it('inStock is false and grouped by product', async () => {
+            // ToDo Remove @ts-ignore after implementing inStock in default-search-plugin
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: true,
+                        // @ts-ignore
+                        inStock: false,
+                    },
+                },
+            );
+            expect(result.search.totalItems).toBe(1);
+        });
+
+        it('inStock is true and not grouped by product', async () => {
+            // ToDo Remove @ts-ignore after implementing inStock in default-search-plugin
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: false,
+                        // @ts-ignore
+                        inStock: true,
+                    },
+                },
+            );
+            expect(result.search.totalItems).toBe(31);
+        });
+
+        it('inStock is true and grouped by product', async () => {
+            // ToDo Remove @ts-ignore after implementing inStock in default-search-plugin
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: true,
+                        // @ts-ignore
+                        inStock: true,
+                    },
+                },
+            );
+            expect(result.search.totalItems).toBe(19);
+        });
+
+        it('inStock is undefined and not grouped by product', async () => {
+            // ToDo Remove @ts-ignore after implementing inStock in default-search-plugin
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: false,
+                        // @ts-ignore
+                        inStock: undefined,
+                    },
+                },
+            );
+            expect(result.search.totalItems).toBe(33);
+        });
+
+        it('inStock is undefined and grouped by product', async () => {
+            // ToDo Remove @ts-ignore after implementing inStock in default-search-plugin
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+                SEARCH_PRODUCTS_SHOP,
+                {
+                    input: {
+                        groupByProduct: true,
+                        // @ts-ignore
+                        inStock: undefined,
+                    },
+                },
+            );
+            expect(result.search.totalItems).toBe(20);
+        });
     });
 
     describe('admin api', () => {

+ 35 - 0
packages/core/e2e/fixtures/e2e-products-default-search.csv

@@ -0,0 +1,35 @@
+name              ,slug              ,description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  ,assets                            ,facets                                 ,optionGroups   ,optionValues     ,sku         ,price  ,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets
+Laptop            ,laptop            ,"Now equipped with seventh-generation Intel Core processors, Laptop is snappier than ever. From daily tasks like launching apps and opening files to more advanced computing, you can power through your day thanks to faster SSDs and Turbo Boost processing up to 3.6GHz."                                                                                                                                                                                                                 ,derick-david-409858-unsplash.jpg  ,category:electronics|category:computers,screen size|RAM,13 inch|8GB      ,L2201308    ,1299.00,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,15 inch|8GB      ,L2201508    ,1399.00,standard   ,100        ,false          ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,13 inch|16GB     ,L2201316    ,2199.00,standard   ,100        ,true         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,15 inch|16GB     ,L2201516    ,2299.00,standard   ,0        ,false         ,             ,
+Curvy Monitor     ,curvy-monitor     ,"Discover a truly immersive viewing experience with this monitor curved more deeply than any other. Wrapping around your field of vision the 1,800 R screencreates a wider field of view, enhances depth perception, and minimises peripheral distractions to draw you deeper in to your content."                                                                                                                                                                                           ,alexandru-acea-686569-unsplash.jpg,category:electronics|category:computers,monitor size   ,24 inch          ,C24F390     ,143.74 ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,27 inch          ,C27F390     ,169.94 ,standard   ,100        ,false         ,             ,
+Gaming PC         ,gaming-pc         ,"This pc is optimised for gaming, and is also VR ready. The Intel Core-i7 CPU and High Performance GPU give the computer the raw power it needs to function at a high level."                                                                                                                                                                                                                                                                                                                ,florian-olivo-1166419-unsplash.jpg,category:electronics|category:computers,cpu|HDD        ,i7-8700|240GB SSD,CGS480VR1063,1087.20,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,R7-2700|240GB SSD,CGS480VR1064,1099.95,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,i7-8700|120GB SSD,CGS480VR1065,931.20 ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,R7-2700|120GB SSD,CGS480VR1066,949.20 ,standard   ,100        ,false         ,             ,
+Hard Drive        ,hard-drive        ,"Boost your PC storage with this internal hard drive, designed just for desktop and all-in-one PCs."                                                                                                                                                                                                                                                                                                                                                                                         ,vincent-botta-736919-unsplash.jpg ,category:electronics|category:computers,HDD capacity   ,1TB              ,IHD455T1    ,37.99  ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,2TB              ,IHD455T2    ,53.74  ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,3TB              ,IHD455T3    ,78.96  ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,4TB              ,IHD455T4    ,92.99  ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,6TB              ,IHD455T6    ,134.35 ,standard   ,100        ,false         ,             ,
+Clacky Keyboard   ,clacky-keyboard   ,"Let all your colleagues know that you are typing on this exclusive, colorful klicky-klacky keyboard. Huge travel on each keypress ensures maximum klack on each and every keystroke."                                                                                                                                                                                                                                                                                                       ,                                  ,category:electronics|category:computers,               ,                 ,A4TKLA45535 ,74.89  ,standard   ,100        ,false         ,             ,
+USB Cable         ,usb-cable         ,"Solid conductors eliminate strand-interaction distortion and reduce jitter. As the surface is made of high-purity silver, the performance is very close to that of a solid silver cable, but priced much closer to solid copper cable."                                                                                                                                                                                                                                                     ,                                  ,category:electronics|category:computers,               ,                 ,USBCIN01.5MI,69.00  ,standard   ,100        ,false         ,             ,
+Instant Camera    ,instant-camera    ,"With its nostalgic design and simple point-and-shoot functionality, the Instant Camera is the perfect pick to get started with instant photography."                                                                                                                                                                                                                                                                                                                                        ,                                  ,category:electronics|category:photo    ,               ,                 ,IC22MWDD    ,174.99 ,standard   ,100        ,false         ,             ,
+Camera Lens       ,camera-lens       ,This lens is a Di type lens using an optical system with improved multi-coating designed to function with digital SLR cameras as well as film cameras.                                                                                                                                                                                                                                                                                                                                       ,                                  ,category:electronics|category:photo    ,               ,                 ,B0012UUP02  ,104.00 ,standard   ,100        ,false         ,             ,
+Tripod            ,tripod            ,"Capture vivid, professional-style photographs with help from this lightweight tripod. The adjustable-height tripod makes it easy to achieve reliable stability and score just the right angle when going after that award-winning shot."                                                                                                                                                                                                                                                    ,                                  ,category:electronics|category:photo    ,               ,                 ,B00XI87KV8  ,14.98  ,standard   ,100        ,false         ,             ,
+Slr Camera        ,slr-camera        ,"Retro styled, portable in size and built around a powerful 24-megapixel APS-C CMOS sensor, this digital camera is the ideal companion for creative everyday photography. Packed full of high spec features such as an advanced hybrid autofocus system able to keep pace with even the most active subjects, a speedy 6fps continuous-shooting mode, high-resolution electronic viewfinder and intuitive swivelling touchscreen, it brings professional image making into everyone’s grasp.",                                  ,category:electronics|category:photo    ,               ,                 ,B07D75V44S  ,521.00 ,standard   ,100        ,false         ,             ,
+Road Bike         ,road-bike         ,"Featuring a full carbon chassis - complete with cyclocross-specific carbon fork - and a component setup geared for hard use on the race circuit, it's got the low weight, exceptional efficiency and brilliant handling you'll need to stay at the front of the pack."                                                                                                                                                                                                                      ,                                  ,category:sports equipment              ,               ,                 ,RB000844334 ,2499.00,standard   ,100        ,false         ,             ,
+Skipping Rope     ,skipping-rope     ,When you're working out you need a quality rope that doesn't tangle at every couple of jumps and with this sipping rope you won't have this problem.                                                                                                                                                                                                                                                                                                                                         ,                                  ,category:sports equipment              ,               ,                 ,B07CNGXVXT  ,7.99   ,standard   ,100        ,false         ,             ,
+Boxing Gloves     ,boxing-gloves     ,"Training gloves designed for optimum training. Our gloves promote proper punching technique because they are conformed to the natural shape of your fist. Dense, innovative two-layer foam provides better shock absorbency and full padding on the front, back and wrist to promote proper punching technique."                                                                                                                                                                            ,                                  ,category:sports equipment              ,               ,                 ,B000ZYLPPU  ,33.04  ,standard   ,100        ,false         ,             ,
+Tent              ,tent              ,"With tons of space inside (for max. 4 persons), full head height throughout the entire tent and an unusual and striking shape, this tent offers you everything you need."                                                                                                                                                                                                                                                                                                                   ,                                  ,category:sports equipment              ,               ,                 ,2000023510  ,214.93 ,standard   ,100        ,false         ,             ,
+Cruiser Skateboard,cruiser-skateboard,"Based on the 1970s iconic shape, but made to a larger 69cm size, with updated, quality component, these skateboards are great for beginners to learn the foot spacing required, and are perfect for all-day cruising."                                                                                                                                                                                                                                                                      ,                                  ,category:sports equipment              ,               ,                 ,799872520   ,24.99  ,standard   ,100        ,false         ,             ,
+Football          ,football          ,"This football features high-contrast graphics for high-visibility during play, while its machine-stitched tpu casing offers consistent performance."                                                                                                                                                                                                                                                                                                                                        ,                                  ,category:sports equipment              ,               ,                 ,SC3137-056  ,57.07  ,standard   ,100        ,false         ,             ,
+Running Shoe      ,running-shoe      ,"With its ultra-light, uber-responsive magic foam and a carbon fiber plate that feels like it’s propelling you forward, the Running Shoe is ready to push you to victories both large and small"                                                                                                                                                                                                                                                                                             ,                                  ,category:sports equipment              ,shoe size      ,Size 40          ,RS0040      ,99.99  ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,Size 42          ,RS0042      ,99.99  ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,Size 44          ,RS0044      ,99.99  ,standard   ,100        ,false         ,             ,
+                  ,                  ,                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             ,                                  ,                                       ,               ,Size 46          ,RS0046      ,99.99  ,standard   ,100        ,false         ,             ,
+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        ,false         ,             ,
+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        ,false         ,             ,
+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        ,false         ,             ,

+ 12 - 0
packages/core/src/plugin/default-search-plugin/api/api-extensions.ts

@@ -0,0 +1,12 @@
+import { DocumentNode } from 'graphql';
+import gql from 'graphql-tag';
+
+export const stockStatusExtension = gql`
+    extend type SearchResult {
+        inStock: Boolean!
+    }
+
+    extend input SearchInput {
+        inStock: Boolean
+    }
+`;

+ 32 - 7
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -1,7 +1,8 @@
-import { Inject, OnApplicationBootstrap } from '@nestjs/common';
+import { OnApplicationBootstrap } from '@nestjs/common';
 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 { idsAreEqual } from '../../common/utils';
 import { EventBus } from '../../event-bus/event-bus';
@@ -12,19 +13,17 @@ import { ProductEvent } from '../../event-bus/events/product-event';
 import { ProductVariantChannelEvent } from '../../event-bus/events/product-variant-channel-event';
 import { ProductVariantEvent } from '../../event-bus/events/product-variant-event';
 import { TaxRateModificationEvent } from '../../event-bus/events/tax-rate-modification-event';
-import { JobBufferService } from '../../job-queue/job-buffer/job-buffer.service';
 import { JobQueueService } from '../../job-queue/job-queue.service';
 import { PluginCommonModule } from '../plugin-common.module';
 import { VendurePlugin } from '../vendure-plugin';
 
+import { stockStatusExtension } from './api/api-extensions';
 import { AdminFulltextSearchResolver, ShopFulltextSearchResolver } from './api/fulltext-search.resolver';
 import { BUFFER_SEARCH_INDEX_UPDATES, PLUGIN_INIT_OPTIONS } from './constants';
 import { SearchIndexItem } from './entities/search-index-item.entity';
 import { FulltextSearchService } from './fulltext-search.service';
 import { IndexerController } from './indexer/indexer.controller';
 import { SearchIndexService } from './indexer/search-index.service';
-import { CollectionJobBuffer } from './search-job-buffer/collection-job-buffer';
-import { SearchIndexJobBuffer } from './search-job-buffer/search-index-job-buffer';
 import { SearchJobBufferService } from './search-job-buffer/search-job-buffer.service';
 import { DefaultSearchPluginInitOptions } from './types';
 
@@ -55,7 +54,10 @@ export interface DefaultSearchReindexResponse extends SearchReindexResponse {
  * export const config: VendureConfig = {
  *   // Add an instance of the plugin to the plugins array
  *   plugins: [
- *     DefaultSearchPlugin,
+ *     DefaultSearchPlugin.init({
+ *       indexStockStatus: true,
+ *       bufferUpdates: true,
+ *     }),
  *   ],
  * };
  * ```
@@ -75,8 +77,16 @@ export interface DefaultSearchReindexResponse extends SearchReindexResponse {
             useFactory: () => DefaultSearchPlugin.options.bufferUpdates === true,
         },
     ],
-    adminApiExtensions: { resolvers: [AdminFulltextSearchResolver] },
-    shopApiExtensions: { resolvers: [ShopFulltextSearchResolver] },
+    adminApiExtensions: {
+        schema: () =>
+            DefaultSearchPlugin.options.indexStockStatus === true ? stockStatusExtension : undefined,
+        resolvers: [AdminFulltextSearchResolver],
+    },
+    shopApiExtensions: {
+        schema: () =>
+            DefaultSearchPlugin.options.indexStockStatus === true ? stockStatusExtension : undefined,
+        resolvers: [ShopFulltextSearchResolver],
+    },
     entities: [SearchIndexItem],
 })
 export class DefaultSearchPlugin implements OnApplicationBootstrap {
@@ -91,6 +101,9 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap {
 
     static init(options: DefaultSearchPluginInitOptions): Type<DefaultSearchPlugin> {
         this.options = options;
+        if (options.indexStockStatus === true) {
+            this.addStockColumnsToEntity();
+        }
         return DefaultSearchPlugin;
     }
 
@@ -180,4 +193,16 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap {
                 }
             });
     }
+
+    /**
+     * If the `indexStockStatus` option is set to `true`, we dynamically add a couple of
+     * columns to the SearchIndexItem entity. This is done in this way to allow us to add
+     * support for indexing the stock status, while preventing a backwards-incompatible
+     * schema change.
+     */
+    private static addStockColumnsToEntity() {
+        const instance = new SearchIndexItem();
+        Column({ type: 'boolean', default: true })(instance, 'inStock');
+        Column({ type: 'boolean', default: true })(instance, 'productInStock');
+    }
 }

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

@@ -85,4 +85,9 @@ export class SearchIndexItem {
 
     @EntityId({ nullable: true })
     productVariantAssetId: ID | null;
+
+    // Added dynamically based on the `indexStockStatus` init option.
+    inStock?: boolean;
+    // Added dynamically based on the `indexStockStatus` init option.
+    productInStock?: boolean;
 }

+ 7 - 4
packages/core/src/plugin/default-search-plugin/fulltext-search.service.ts

@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
 import { SearchInput, SearchResponse } from '@vendure/common/lib/generated-types';
 import { Omit } from '@vendure/common/lib/omit';
 
@@ -13,11 +13,13 @@ import { FacetValueService } from '../../service/services/facet-value.service';
 import { ProductVariantService } from '../../service/services/product-variant.service';
 import { SearchService } from '../../service/services/search.service';
 
+import { PLUGIN_INIT_OPTIONS } from './constants';
 import { SearchIndexService } from './indexer/search-index.service';
 import { MysqlSearchStrategy } from './search-strategy/mysql-search-strategy';
 import { PostgresSearchStrategy } from './search-strategy/postgres-search-strategy';
 import { SearchStrategy } from './search-strategy/search-strategy';
 import { SqliteSearchStrategy } from './search-strategy/sqlite-search-strategy';
+import { DefaultSearchPluginInitOptions } from './types';
 
 /**
  * Search indexing and full-text search for supported databases. See the various
@@ -36,6 +38,7 @@ export class FulltextSearchService {
         private productVariantService: ProductVariantService,
         private searchIndexService: SearchIndexService,
         private searchService: SearchService,
+        @Inject(PLUGIN_INIT_OPTIONS) private options: DefaultSearchPluginInitOptions,
     ) {
         this.searchService.adopt(this);
         this.setSearchStrategy();
@@ -108,15 +111,15 @@ export class FulltextSearchService {
         switch (this.connection.rawConnection.options.type) {
             case 'mysql':
             case 'mariadb':
-                this.searchStrategy = new MysqlSearchStrategy(this.connection);
+                this.searchStrategy = new MysqlSearchStrategy(this.connection, this.options);
                 break;
             case 'sqlite':
             case 'sqljs':
             case 'better-sqlite3':
-                this.searchStrategy = new SqliteSearchStrategy(this.connection);
+                this.searchStrategy = new SqliteSearchStrategy(this.connection, this.options);
                 break;
             case 'postgres':
-                this.searchStrategy = new PostgresSearchStrategy(this.connection);
+                this.searchStrategy = new PostgresSearchStrategy(this.connection, this.options);
                 break;
             default:
                 throw new InternalServerError(`error.database-not-supported-by-default-search-plugin`);

+ 56 - 36
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
@@ -6,6 +6,7 @@ import { Observable } from 'rxjs';
 import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
 
 import { RequestContext } from '../../../api/common/request-context';
+import { RequestContextCacheService } from '../../../cache/request-context-cache.service';
 import { AsyncQueue } from '../../../common/async-queue';
 import { Translatable, Translation } from '../../../common/types/locale-types';
 import { asyncObservable, idsAreEqual } from '../../../common/utils';
@@ -16,8 +17,10 @@ import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { ProductPriceApplicator } from '../../../service/helpers/product-price-applicator/product-price-applicator';
+import { PLUGIN_INIT_OPTIONS } from '../constants';
 import { SearchIndexItem } from '../entities/search-index-item.entity';
 import {
+    DefaultSearchPluginInitOptions,
     ProductChannelMessageData,
     ReindexMessageData,
     ReindexMessageResponse,
@@ -54,6 +57,8 @@ export class IndexerController {
         private connection: TransactionalConnection,
         private productPriceApplicator: ProductPriceApplicator,
         private configService: ConfigService,
+        private requestContextCache: RequestContextCacheService,
+        @Inject(PLUGIN_INIT_OPTIONS) private options: DefaultSearchPluginInitOptions,
     ) {}
 
     reindex({ ctx: rawContext }: ReindexMessageData): Observable<ReindexMessageResponse> {
@@ -334,41 +339,56 @@ export class IndexerController {
                         session: {} as any,
                     });
                     await this.productPriceApplicator.applyChannelPriceAndTax(variant, ctx);
-                    items.push(
-                        new SearchIndexItem({
-                            channelId: channel.id,
-                            languageCode,
-                            productVariantId: variant.id,
-                            price: variant.price,
-                            priceWithTax: variant.priceWithTax,
-                            sku: variant.sku,
-                            enabled: variant.product.enabled === false ? false : variant.enabled,
-                            slug: productTranslation.slug,
-                            productId: variant.product.id,
-                            productName: productTranslation.name,
-                            description: this.constrainDescription(productTranslation.description),
-                            productVariantName: variantTranslation.name,
-                            productAssetId: variant.product.featuredAsset
-                                ? variant.product.featuredAsset.id
-                                : null,
-                            productPreviewFocalPoint: variant.product.featuredAsset
-                                ? variant.product.featuredAsset.focalPoint
-                                : null,
-                            productVariantPreviewFocalPoint: variant.featuredAsset
-                                ? variant.featuredAsset.focalPoint
-                                : null,
-                            productVariantAssetId: variant.featuredAsset ? variant.featuredAsset.id : null,
-                            productPreview: variant.product.featuredAsset
-                                ? variant.product.featuredAsset.preview
-                                : '',
-                            productVariantPreview: variant.featuredAsset ? variant.featuredAsset.preview : '',
-                            channelIds: variant.channels.map(c => c.id as string),
-                            facetIds: this.getFacetIds(variant),
-                            facetValueIds: this.getFacetValueIds(variant),
-                            collectionIds: variant.collections.map(c => c.id.toString()),
-                            collectionSlugs: collectionTranslations.map(c => c.slug),
-                        }),
-                    );
+                    const item = new SearchIndexItem({
+                        channelId: channel.id,
+                        languageCode,
+                        productVariantId: variant.id,
+                        price: variant.price,
+                        priceWithTax: variant.priceWithTax,
+                        sku: variant.sku,
+                        enabled: variant.product.enabled === false ? false : variant.enabled,
+                        slug: productTranslation.slug,
+                        productId: variant.product.id,
+                        productName: productTranslation.name,
+                        description: this.constrainDescription(productTranslation.description),
+                        productVariantName: variantTranslation.name,
+                        productAssetId: variant.product.featuredAsset
+                            ? variant.product.featuredAsset.id
+                            : null,
+                        productPreviewFocalPoint: variant.product.featuredAsset
+                            ? variant.product.featuredAsset.focalPoint
+                            : null,
+                        productVariantPreviewFocalPoint: variant.featuredAsset
+                            ? variant.featuredAsset.focalPoint
+                            : null,
+                        productVariantAssetId: variant.featuredAsset ? variant.featuredAsset.id : null,
+                        productPreview: variant.product.featuredAsset
+                            ? variant.product.featuredAsset.preview
+                            : '',
+                        productVariantPreview: variant.featuredAsset ? variant.featuredAsset.preview : '',
+                        channelIds: variant.channels.map(c => c.id as string),
+                        facetIds: this.getFacetIds(variant),
+                        facetValueIds: this.getFacetValueIds(variant),
+                        collectionIds: variant.collections.map(c => c.id.toString()),
+                        collectionSlugs: collectionTranslations.map(c => c.slug),
+                    });
+                    if (this.options.indexStockStatus) {
+                        item.inStock = 0 < variant.stockOnHand;
+                        const productInStock = await this.requestContextCache.get(
+                            ctx,
+                            `productVariantsStock-${variant.productId}`,
+                            () =>
+                                this.connection
+                                    .getRepository(ctx, ProductVariant)
+                                    .createQueryBuilder('variant')
+                                    .select('variant.stockOnHand', 'stock')
+                                    .where('variant.productId = :productId', { productId: variant.productId })
+                                    .getRawMany()
+                                    .then(rows => rows.some(row => 0 < row.stock)),
+                        );
+                        item.productInStock = productInStock;
+                    }
+                    items.push(item);
                 }
             }
         }

+ 17 - 5
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -1,4 +1,4 @@
-import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
+import { LogicalOperator, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { Brackets, SelectQueryBuilder } from 'typeorm';
 
@@ -6,9 +6,10 @@ import { RequestContext } from '../../../api/common/request-context';
 import { UserInputError } from '../../../common/error/errors';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { SearchIndexItem } from '../entities/search-index-item.entity';
+import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 
 import { SearchStrategy } from './search-strategy';
-import { fieldsToSelect } from './search-strategy-common';
+import { getFieldsToSelect } from './search-strategy-common';
 import {
     createCollectionIdCountMap,
     createFacetIdCountMap,
@@ -21,7 +22,10 @@ import {
 export class MysqlSearchStrategy implements SearchStrategy {
     private readonly minTermLength = 2;
 
-    constructor(private connection: TransactionalConnection) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private options: DefaultSearchPluginInitOptions,
+    ) {}
 
     async getFacetValueIds(
         ctx: RequestContext,
@@ -170,6 +174,13 @@ export class MysqlSearchStrategy implements SearchStrategy {
         } else {
             qb.addSelect('1 as score');
         }
+        if (input.inStock != null) {
+            if (input.groupByProduct) {
+                qb.andWhere('productInStock = :inStock', { inStock: input.inStock });
+            } else {
+                qb.andWhere('inStock = :inStock', { inStock: input.inStock });
+            }
+        }
         if (facetValueIds?.length) {
             qb.andWhere(
                 new Brackets(qb1 => {
@@ -229,13 +240,14 @@ export class MysqlSearchStrategy implements SearchStrategy {
         }
         return qb;
     }
+
     /**
      * When a select statement includes a GROUP BY clause,
      * then all selected columns must be aggregated. So we just apply the
      * "MIN" function in this case to all other columns than the productId.
      */
     private createMysqlSelect(groupByProduct: boolean): string {
-        return fieldsToSelect
+        return getFieldsToSelect(this.options.indexStockStatus)
             .map(col => {
                 const qualifiedName = `si.${col}`;
                 const alias = `si_${col}`;
@@ -247,7 +259,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
                         col === 'channelIds'
                     ) {
                         return `GROUP_CONCAT(${qualifiedName}) as "${alias}"`;
-                    } else if (col === 'enabled') {
+                    } else if (col === 'enabled' || col === 'inStock' || col === 'productInStock') {
                         return `MAX(${qualifiedName}) as "${alias}"`;
                     } else {
                         return `MIN(${qualifiedName}) as "${alias}"`;

+ 16 - 5
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -1,4 +1,4 @@
-import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
+import { LogicalOperator, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { Brackets, SelectQueryBuilder } from 'typeorm';
 
@@ -6,9 +6,10 @@ import { RequestContext } from '../../../api/common/request-context';
 import { UserInputError } from '../../../common/error/errors';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { SearchIndexItem } from '../entities/search-index-item.entity';
+import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 
 import { SearchStrategy } from './search-strategy';
-import { fieldsToSelect } from './search-strategy-common';
+import { getFieldsToSelect } from './search-strategy-common';
 import {
     createCollectionIdCountMap,
     createFacetIdCountMap,
@@ -21,7 +22,10 @@ import {
 export class PostgresSearchStrategy implements SearchStrategy {
     private readonly minTermLength = 2;
 
-    constructor(private connection: TransactionalConnection) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private options: DefaultSearchPluginInitOptions,
+    ) {}
 
     async getFacetValueIds(
         ctx: RequestContext,
@@ -168,6 +172,13 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 )
                 .setParameters({ term: termLogicalAnd });
         }
+        if (input.inStock != null) {
+            if (input.groupByProduct) {
+                qb.andWhere('si.productInStock = :inStock', { inStock: input.inStock });
+            } else {
+                qb.andWhere('si.inStock = :inStock', { inStock: input.inStock });
+            }
+        }
         if (facetValueIds?.length) {
             qb.andWhere(
                 new Brackets(qb1 => {
@@ -235,7 +246,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
      * "MIN" function in this case to all other columns than the productId.
      */
     private createPostgresSelect(groupByProduct: boolean): string {
-        return fieldsToSelect
+        return getFieldsToSelect(this.options.indexStockStatus)
             .map(col => {
                 const qualifiedName = `si.${col}`;
                 const alias = `si_${col}`;
@@ -247,7 +258,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
                         col === 'channelIds'
                     ) {
                         return `string_agg(${qualifiedName}, ',') as "${alias}"`;
-                    } else if (col === 'enabled') {
+                    } else if (col === 'enabled' || col === 'inStock' || col === 'productInStock') {
                         return `bool_or(${qualifiedName}) as "${alias}"`;
                     } else {
                         return `MIN(${qualifiedName}) as "${alias}"`;

+ 4 - 0
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-common.ts

@@ -21,3 +21,7 @@ export const fieldsToSelect = [
     'productVariantPreview',
     'productVariantPreviewFocalPoint',
 ];
+
+export function getFieldsToSelect(includeStockStatus: boolean = false) {
+    return includeStockStatus ? [...fieldsToSelect, 'inStock', 'productInStock'] : fieldsToSelect;
+}

+ 2 - 0
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-utils.ts

@@ -57,6 +57,8 @@ export function mapToSearchResult(raw: any, currencyCode: CurrencyCode): SearchR
         productAsset,
         productVariantAsset,
         score: raw.score || 0,
+        // @ts-ignore
+        inStock: raw.si_inStock,
     };
 }
 

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

@@ -1,4 +1,4 @@
-import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
+import { LogicalOperator, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { Brackets, SelectQueryBuilder } from 'typeorm';
 
@@ -6,6 +6,7 @@ import { RequestContext } from '../../../api/common/request-context';
 import { UserInputError } from '../../../common/error/errors';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { SearchIndexItem } from '../entities/search-index-item.entity';
+import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 
 import { SearchStrategy } from './search-strategy';
 import {
@@ -21,7 +22,10 @@ import {
 export class SqliteSearchStrategy implements SearchStrategy {
     private readonly minTermLength = 2;
 
-    constructor(private connection: TransactionalConnection) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private options: DefaultSearchPluginInitOptions,
+    ) {}
 
     async getFacetValueIds(
         ctx: RequestContext,
@@ -157,6 +161,13 @@ export class SqliteSearchStrategy implements SearchStrategy {
                 )
                 .setParameters({ term, like_term: `%${term}%` });
         }
+        if (input.inStock != null) {
+            if (input.groupByProduct) {
+                qb.andWhere('productInStock = :inStock', { inStock: input.inStock });
+            } else {
+                qb.andWhere('inStock = :inStock', { inStock: input.inStock });
+            }
+        }
         if (facetValueIds?.length) {
             qb.andWhere(
                 new Brackets(qb1 => {

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

@@ -1,13 +1,38 @@
+import { SearchInput as GeneratedSearchInput } from '@vendure/common/lib/generated-types';
 import { ID, JsonCompatible } from '@vendure/common/lib/shared-types';
 
 import { SerializedRequestContext } from '../../api/common/request-context';
 import { Asset } from '../../entity/asset/asset.entity';
 
+/**
+ * @description
+ * Options which configure the behaviour of the DefaultSearchPlugin
+ *
+ * @docsCategory DefaultSearchPlugin
+ */
 export interface DefaultSearchPluginInitOptions {
+    /**
+     * @description
+     * If set to `true`, the stock status of a ProductVariant (isStock: Boolean) will
+     * be exposed in the `search` query results. Enabling this option on an existing
+     * Vendure installation will require a DB migration/synchronization.
+     *
+     * @default false.
+     */
+    indexStockStatus?: boolean;
     // TODO: docs
     bufferUpdates?: boolean;
 }
 
+/**
+ * Because the `inStock` field is opt-in based on the `indexStockStatus` option,
+ * it is not included by default in the generated types. Thus we manually augment
+ * the generated type here.
+ */
+export interface SearchInput extends GeneratedSearchInput {
+    inStock?: boolean;
+}
+
 export type ReindexMessageResponse = {
     total: number;
     completed: number;

+ 1 - 1
packages/core/src/plugin/vendure-plugin.ts

@@ -65,7 +65,7 @@ export interface APIExtensionDefinition {
      * }`;
      * ```
      */
-    schema?: DocumentNode | (() => DocumentNode);
+    schema?: DocumentNode | (() => DocumentNode | undefined);
     /**
      * @description
      * An array of resolvers for the schema extensions. Should be defined as [Nestjs GraphQL resolver](https://docs.nestjs.com/graphql/resolvers-map)