Quellcode durchsuchen

feat(search): add to SearchResponse collections array (#956)

Closes #943
Artem Danilov vor 4 Jahren
Ursprung
Commit
43431548fc
26 geänderte Dateien mit 3826 neuen und 2905 gelöschten Zeilen
  1. 11 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 256 189
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 1 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  4. 665 518
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  5. 622 585
      packages/common/src/generated-shop-types.ts
  6. 11 0
      packages/common/src/generated-types.ts
  7. 45 1
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  8. 665 518
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  9. 594 557
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  10. 7 2
      packages/core/src/api/resolvers/admin/search.resolver.ts
  11. 10 0
      packages/core/src/api/schema/common/product-search.type.graphql
  12. 20 3
      packages/core/src/plugin/default-search-plugin/fulltext-search.resolver.ts
  13. 22 2
      packages/core/src/plugin/default-search-plugin/fulltext-search.service.ts
  14. 23 1
      packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts
  15. 23 1
      packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts
  16. 17 0
      packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-utils.ts
  17. 5 0
      packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy.ts
  18. 23 1
      packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts
  19. 12 0
      packages/core/src/service/services/collection.service.ts
  20. 45 1
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  21. 665 518
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  22. 16 7
      packages/elasticsearch-plugin/src/elasticsearch-resolver.ts
  23. 57 1
      packages/elasticsearch-plugin/src/elasticsearch.service.ts
  24. 11 0
      packages/elasticsearch-plugin/src/options.ts
  25. 0 0
      schema-admin.json
  26. 0 0
      schema-shop.json

+ 11 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -4443,6 +4443,7 @@ export type SearchResponse = {
   items: Array<SearchResult>;
   totalItems: Scalars['Int'];
   facetValues: Array<FacetValueResult>;
+  collections: Array<CollectionResult>;
 };
 
 /**
@@ -4455,6 +4456,16 @@ export type FacetValueResult = {
   count: Scalars['Int'];
 };
 
+/**
+ * Which Collections are present in the products returned
+ * by the search, and in what quantity.
+ */
+export type CollectionResult = {
+  __typename?: 'CollectionResult';
+  collection: Collection;
+  count: Scalars['Int'];
+};
+
 export type SearchResultAsset = {
   __typename?: 'SearchResultAsset';
   id: Scalars['ID'];

+ 256 - 189
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -1,192 +1,259 @@
 // tslint:disable
 
-export interface PossibleTypesResultData {
-    possibleTypes: {
-        [key: string]: string[];
-    };
-}
-const result: PossibleTypesResultData = {
-    possibleTypes: {
-        CreateAssetResult: ['Asset', 'MimeTypeError'],
-        NativeAuthenticationResult: ['CurrentUser', 'InvalidCredentialsError', 'NativeAuthStrategyError'],
-        AuthenticationResult: ['CurrentUser', 'InvalidCredentialsError'],
-        CreateChannelResult: ['Channel', 'LanguageNotAvailableError'],
-        UpdateChannelResult: ['Channel', 'LanguageNotAvailableError'],
-        CreateCustomerResult: ['Customer', 'EmailAddressConflictError'],
-        UpdateCustomerResult: ['Customer', 'EmailAddressConflictError'],
-        UpdateGlobalSettingsResult: ['GlobalSettings', 'ChannelDefaultLanguageError'],
-        TransitionOrderToStateResult: ['Order', 'OrderStateTransitionError'],
-        SettlePaymentResult: [
-            'Payment',
-            'SettlePaymentError',
-            'PaymentStateTransitionError',
-            'OrderStateTransitionError',
-        ],
-        AddFulfillmentToOrderResult: [
-            'Fulfillment',
-            'EmptyOrderLineSelectionError',
-            'ItemsAlreadyFulfilledError',
-            'InsufficientStockOnHandError',
-            'InvalidFulfillmentHandlerError',
-            'FulfillmentStateTransitionError',
-            'CreateFulfillmentError',
-        ],
-        CancelOrderResult: [
-            'Order',
-            'EmptyOrderLineSelectionError',
-            'QuantityTooGreatError',
-            'MultipleOrderError',
-            'CancelActiveOrderError',
-            'OrderStateTransitionError',
-        ],
-        RefundOrderResult: [
-            'Refund',
-            'QuantityTooGreatError',
-            'NothingToRefundError',
-            'OrderStateTransitionError',
-            'MultipleOrderError',
-            'PaymentOrderMismatchError',
-            'RefundOrderStateError',
-            'AlreadyRefundedError',
-            'RefundStateTransitionError',
-        ],
-        SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
-        TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
-        TransitionPaymentToStateResult: ['Payment', 'PaymentStateTransitionError'],
-        ModifyOrderResult: [
-            'Order',
-            'NoChangesSpecifiedError',
-            'OrderModificationStateError',
-            'PaymentMethodMissingError',
-            'RefundPaymentIdMissingError',
-            'OrderLimitError',
-            'NegativeQuantityError',
-            'InsufficientStockError',
-        ],
-        AddManualPaymentToOrderResult: ['Order', 'ManualPaymentStateError'],
-        RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
-        CreatePromotionResult: ['Promotion', 'MissingConditionsError'],
-        UpdatePromotionResult: ['Promotion', 'MissingConditionsError'],
-        StockMovement: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
-        StockMovementItem: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
-        PaginatedList: [
-            'AdministratorList',
-            'CustomerGroupList',
-            'JobList',
-            'PaymentMethodList',
-            'AssetList',
-            'CollectionList',
-            'ProductVariantList',
-            'CountryList',
-            'CustomerList',
-            'FacetList',
-            'HistoryEntryList',
-            'OrderList',
-            'ProductList',
-            'PromotionList',
-            'RoleList',
-            'ShippingMethodList',
-            'TagList',
-            'TaxRateList',
-        ],
-        Node: [
-            'Administrator',
-            'Asset',
-            'Collection',
-            'Customer',
-            'Facet',
-            'HistoryEntry',
-            'Job',
-            'Order',
-            'Fulfillment',
-            'Payment',
-            'OrderModification',
-            'PaymentMethod',
-            'Product',
-            'ProductVariant',
-            'StockAdjustment',
-            'Allocation',
-            'Sale',
-            'Cancellation',
-            'Return',
-            'Release',
-            'Address',
-            'Channel',
-            'Country',
-            'CustomerGroup',
-            'FacetValue',
-            'OrderItem',
-            'OrderLine',
-            'Refund',
-            'Surcharge',
-            'ProductOptionGroup',
-            'ProductOption',
-            'Promotion',
-            'Role',
-            'ShippingMethod',
-            'Tag',
-            'TaxCategory',
-            'TaxRate',
-            'User',
-            'AuthenticationMethod',
-            'Zone',
-        ],
-        ErrorResult: [
-            'MimeTypeError',
-            'LanguageNotAvailableError',
-            'ChannelDefaultLanguageError',
-            'SettlePaymentError',
-            'EmptyOrderLineSelectionError',
-            'ItemsAlreadyFulfilledError',
-            'InvalidFulfillmentHandlerError',
-            'CreateFulfillmentError',
-            'InsufficientStockOnHandError',
-            'MultipleOrderError',
-            'CancelActiveOrderError',
-            'PaymentOrderMismatchError',
-            'RefundOrderStateError',
-            'NothingToRefundError',
-            'AlreadyRefundedError',
-            'QuantityTooGreatError',
-            'RefundStateTransitionError',
-            'PaymentStateTransitionError',
-            'FulfillmentStateTransitionError',
-            'OrderModificationStateError',
-            'NoChangesSpecifiedError',
-            'PaymentMethodMissingError',
-            'RefundPaymentIdMissingError',
-            'ManualPaymentStateError',
-            'ProductOptionInUseError',
-            'MissingConditionsError',
-            'NativeAuthStrategyError',
-            'InvalidCredentialsError',
-            'OrderStateTransitionError',
-            'EmailAddressConflictError',
-            'OrderLimitError',
-            'NegativeQuantityError',
-            'InsufficientStockError',
-        ],
-        CustomField: [
-            'StringCustomFieldConfig',
-            'LocaleStringCustomFieldConfig',
-            'IntCustomFieldConfig',
-            'FloatCustomFieldConfig',
-            'BooleanCustomFieldConfig',
-            'DateTimeCustomFieldConfig',
-            'RelationCustomFieldConfig',
-            'TextCustomFieldConfig',
-        ],
-        CustomFieldConfig: [
-            'StringCustomFieldConfig',
-            'LocaleStringCustomFieldConfig',
-            'IntCustomFieldConfig',
-            'FloatCustomFieldConfig',
-            'BooleanCustomFieldConfig',
-            'DateTimeCustomFieldConfig',
-            'RelationCustomFieldConfig',
-            'TextCustomFieldConfig',
-        ],
-        SearchResultPrice: ['PriceRange', 'SinglePrice'],
-    },
+      export interface PossibleTypesResultData {
+        possibleTypes: {
+          [key: string]: string[]
+        }
+      }
+      const result: PossibleTypesResultData = {
+  "possibleTypes": {
+    "CreateAssetResult": [
+      "Asset",
+      "MimeTypeError"
+    ],
+    "NativeAuthenticationResult": [
+      "CurrentUser",
+      "InvalidCredentialsError",
+      "NativeAuthStrategyError"
+    ],
+    "AuthenticationResult": [
+      "CurrentUser",
+      "InvalidCredentialsError"
+    ],
+    "CreateChannelResult": [
+      "Channel",
+      "LanguageNotAvailableError"
+    ],
+    "UpdateChannelResult": [
+      "Channel",
+      "LanguageNotAvailableError"
+    ],
+    "CreateCustomerResult": [
+      "Customer",
+      "EmailAddressConflictError"
+    ],
+    "UpdateCustomerResult": [
+      "Customer",
+      "EmailAddressConflictError"
+    ],
+    "UpdateGlobalSettingsResult": [
+      "GlobalSettings",
+      "ChannelDefaultLanguageError"
+    ],
+    "TransitionOrderToStateResult": [
+      "Order",
+      "OrderStateTransitionError"
+    ],
+    "SettlePaymentResult": [
+      "Payment",
+      "SettlePaymentError",
+      "PaymentStateTransitionError",
+      "OrderStateTransitionError"
+    ],
+    "AddFulfillmentToOrderResult": [
+      "Fulfillment",
+      "EmptyOrderLineSelectionError",
+      "ItemsAlreadyFulfilledError",
+      "InsufficientStockOnHandError",
+      "InvalidFulfillmentHandlerError",
+      "FulfillmentStateTransitionError",
+      "CreateFulfillmentError"
+    ],
+    "CancelOrderResult": [
+      "Order",
+      "EmptyOrderLineSelectionError",
+      "QuantityTooGreatError",
+      "MultipleOrderError",
+      "CancelActiveOrderError",
+      "OrderStateTransitionError"
+    ],
+    "RefundOrderResult": [
+      "Refund",
+      "QuantityTooGreatError",
+      "NothingToRefundError",
+      "OrderStateTransitionError",
+      "MultipleOrderError",
+      "PaymentOrderMismatchError",
+      "RefundOrderStateError",
+      "AlreadyRefundedError",
+      "RefundStateTransitionError"
+    ],
+    "SettleRefundResult": [
+      "Refund",
+      "RefundStateTransitionError"
+    ],
+    "TransitionFulfillmentToStateResult": [
+      "Fulfillment",
+      "FulfillmentStateTransitionError"
+    ],
+    "TransitionPaymentToStateResult": [
+      "Payment",
+      "PaymentStateTransitionError"
+    ],
+    "ModifyOrderResult": [
+      "Order",
+      "NoChangesSpecifiedError",
+      "OrderModificationStateError",
+      "PaymentMethodMissingError",
+      "RefundPaymentIdMissingError",
+      "OrderLimitError",
+      "NegativeQuantityError",
+      "InsufficientStockError"
+    ],
+    "AddManualPaymentToOrderResult": [
+      "Order",
+      "ManualPaymentStateError"
+    ],
+    "RemoveOptionGroupFromProductResult": [
+      "Product",
+      "ProductOptionInUseError"
+    ],
+    "CreatePromotionResult": [
+      "Promotion",
+      "MissingConditionsError"
+    ],
+    "UpdatePromotionResult": [
+      "Promotion",
+      "MissingConditionsError"
+    ],
+    "StockMovement": [
+      "StockAdjustment",
+      "Allocation",
+      "Sale",
+      "Cancellation",
+      "Return",
+      "Release"
+    ],
+    "StockMovementItem": [
+      "StockAdjustment",
+      "Allocation",
+      "Sale",
+      "Cancellation",
+      "Return",
+      "Release"
+    ],
+    "PaginatedList": [
+      "AdministratorList",
+      "CustomerGroupList",
+      "JobList",
+      "PaymentMethodList",
+      "AssetList",
+      "CollectionList",
+      "ProductVariantList",
+      "CountryList",
+      "CustomerList",
+      "FacetList",
+      "HistoryEntryList",
+      "OrderList",
+      "ProductList",
+      "PromotionList",
+      "RoleList",
+      "ShippingMethodList",
+      "TagList",
+      "TaxRateList"
+    ],
+    "Node": [
+      "Administrator",
+      "Asset",
+      "Collection",
+      "Customer",
+      "Facet",
+      "HistoryEntry",
+      "Job",
+      "Order",
+      "Fulfillment",
+      "Payment",
+      "OrderModification",
+      "PaymentMethod",
+      "Product",
+      "ProductVariant",
+      "StockAdjustment",
+      "Allocation",
+      "Sale",
+      "Cancellation",
+      "Return",
+      "Release",
+      "Address",
+      "Channel",
+      "Country",
+      "CustomerGroup",
+      "FacetValue",
+      "OrderItem",
+      "OrderLine",
+      "Refund",
+      "Surcharge",
+      "ProductOptionGroup",
+      "ProductOption",
+      "Promotion",
+      "Role",
+      "ShippingMethod",
+      "Tag",
+      "TaxCategory",
+      "TaxRate",
+      "User",
+      "AuthenticationMethod",
+      "Zone"
+    ],
+    "ErrorResult": [
+      "MimeTypeError",
+      "LanguageNotAvailableError",
+      "ChannelDefaultLanguageError",
+      "SettlePaymentError",
+      "EmptyOrderLineSelectionError",
+      "ItemsAlreadyFulfilledError",
+      "InvalidFulfillmentHandlerError",
+      "CreateFulfillmentError",
+      "InsufficientStockOnHandError",
+      "MultipleOrderError",
+      "CancelActiveOrderError",
+      "PaymentOrderMismatchError",
+      "RefundOrderStateError",
+      "NothingToRefundError",
+      "AlreadyRefundedError",
+      "QuantityTooGreatError",
+      "RefundStateTransitionError",
+      "PaymentStateTransitionError",
+      "FulfillmentStateTransitionError",
+      "OrderModificationStateError",
+      "NoChangesSpecifiedError",
+      "PaymentMethodMissingError",
+      "RefundPaymentIdMissingError",
+      "ManualPaymentStateError",
+      "ProductOptionInUseError",
+      "MissingConditionsError",
+      "NativeAuthStrategyError",
+      "InvalidCredentialsError",
+      "OrderStateTransitionError",
+      "EmailAddressConflictError",
+      "OrderLimitError",
+      "NegativeQuantityError",
+      "InsufficientStockError"
+    ],
+    "CustomField": [
+      "StringCustomFieldConfig",
+      "LocaleStringCustomFieldConfig",
+      "IntCustomFieldConfig",
+      "FloatCustomFieldConfig",
+      "BooleanCustomFieldConfig",
+      "DateTimeCustomFieldConfig",
+      "RelationCustomFieldConfig",
+      "TextCustomFieldConfig"
+    ],
+    "CustomFieldConfig": [
+      "StringCustomFieldConfig",
+      "LocaleStringCustomFieldConfig",
+      "IntCustomFieldConfig",
+      "FloatCustomFieldConfig",
+      "BooleanCustomFieldConfig",
+      "DateTimeCustomFieldConfig",
+      "RelationCustomFieldConfig",
+      "TextCustomFieldConfig"
+    ],
+    "SearchResultPrice": [
+      "PriceRange",
+      "SinglePrice"
+    ]
+  }
 };
-export default result;
+      export default result;
+    

+ 1 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -186,6 +186,7 @@ export * from './shared/dynamic-form-inputs/relation-form-input/relation-form-in
 export * from './shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component';
 export * from './shared/dynamic-form-inputs/select-form-input/select-form-input.component';
 export * from './shared/dynamic-form-inputs/text-form-input/text-form-input.component';
+export * from './shared/dynamic-form-inputs/textarea-form-input/textarea-form-input.component';
 export * from './shared/pipes/asset-preview.pipe';
 export * from './shared/pipes/channel-label.pipe';
 export * from './shared/pipes/custom-field-label.pipe';

Datei-Diff unterdrückt, da er zu groß ist
+ 665 - 518
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts


Datei-Diff unterdrückt, da er zu groß ist
+ 622 - 585
packages/common/src/generated-shop-types.ts


+ 11 - 0
packages/common/src/generated-types.ts

@@ -4405,6 +4405,7 @@ export type SearchResponse = {
   items: Array<SearchResult>;
   totalItems: Scalars['Int'];
   facetValues: Array<FacetValueResult>;
+  collections: Array<CollectionResult>;
 };
 
 /**
@@ -4417,6 +4418,16 @@ export type FacetValueResult = {
   count: Scalars['Int'];
 };
 
+/**
+ * Which Collections are present in the products returned
+ * by the search, and in what quantity.
+ */
+export type CollectionResult = {
+  __typename?: 'CollectionResult';
+  collection: Collection;
+  count: Scalars['Int'];
+};
+
 export type SearchResultAsset = {
   __typename?: 'SearchResultAsset';
   id: Scalars['ID'];

+ 45 - 1
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -31,6 +31,7 @@ import {
     Reindex,
     RemoveProductsFromChannel,
     RemoveProductVariantsFromChannel,
+    SearchCollections,
     SearchFacetValues,
     SearchGetAssets,
     SearchGetPrices,
@@ -41,7 +42,7 @@ import {
     UpdateCollection,
     UpdateProduct,
     UpdateProductVariants,
-    UpdateTaxRate,
+    UpdateTaxRate
 } from './graphql/generated-e2e-admin-types';
 import { LogicalOperator, SearchProductsShop } from './graphql/generated-e2e-shop-types';
 import {
@@ -554,6 +555,34 @@ describe('Default search plugin', () => {
             ]);
         });
 
+        it('returns correct collections when not grouped by product', async () => {
+            const result = await shopClient.query<SearchCollections.Query, SearchCollections.Variables>(
+                SEARCH_GET_COLLECTIONS,
+                {
+                    input: {
+                        groupByProduct: false,
+                    },
+                },
+            );
+            expect(result.search.collections).toEqual([
+                {collection: {id: 'T_2', name: 'Plants',},count: 3,},
+            ]);
+        });
+
+        it('returns correct collections when grouped by product', async () => {
+            const result = await shopClient.query<SearchCollections.Query, SearchCollections.Variables>(
+                SEARCH_GET_COLLECTIONS,
+                {
+                    input: {
+                        groupByProduct: true,
+                    },
+                },
+            );
+            expect(result.search.collections).toEqual([
+                {collection: {id: 'T_2', name: 'Plants',},count: 3,},
+                ]);
+        });
+
         it('encodes the productId and productVariantId', async () => {
             const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
                 SEARCH_PRODUCTS_SHOP,
@@ -1476,6 +1505,21 @@ export const SEARCH_GET_FACET_VALUES = gql`
     }
 `;
 
+export const SEARCH_GET_COLLECTIONS = gql`
+    query SearchCollections($input: SearchInput!) {
+        search(input: $input) {
+            totalItems
+            collections {
+                count
+                collection {
+                    id
+                    name
+                }
+            }
+        }
+    }
+`;
+
 export const SEARCH_GET_ASSETS = gql`
     query SearchGetAssets($input: SearchInput!) {
         search(input: $input) {

Datei-Diff unterdrückt, da er zu groß ist
+ 665 - 518
packages/core/e2e/graphql/generated-e2e-admin-types.ts


Datei-Diff unterdrückt, da er zu groß ist
+ 594 - 557
packages/core/e2e/graphql/generated-e2e-shop-types.ts


+ 7 - 2
packages/core/src/api/resolvers/admin/search.resolver.ts

@@ -4,14 +4,14 @@ import { Omit } from '@vendure/common/lib/omit';
 
 import { InternalServerError } from '../../../common/error/errors';
 import { Translated } from '../../../common/types/locale-types';
-import { FacetValue } from '../../../entity';
+import { Collection, FacetValue } from '../../../entity';
 import { Allow } from '../../decorators/allow.decorator';
 
 @Resolver()
 export class SearchResolver {
     @Query()
     @Allow(Permission.ReadCatalog, Permission.ReadProduct)
-    async search(...args: any): Promise<Omit<SearchResponse, 'facetValues'>> {
+    async search(...args: any): Promise<Omit<SearchResponse, 'facetValues' | 'collections'>> {
         throw new InternalServerError(`error.no-search-plugin-configured`);
     }
 
@@ -20,6 +20,11 @@ export class SearchResolver {
         throw new InternalServerError(`error.no-search-plugin-configured`);
     }
 
+    @ResolveField()
+    async collections(...args: any[]): Promise<Array<{ collection: Collection; count: number }>> {
+        throw new InternalServerError(`error.no-search-plugin-configured`);
+    }
+
     @Mutation()
     @Allow(Permission.UpdateCatalog, Permission.UpdateProduct)
     async reindex(...args: any[]): Promise<any> {

+ 10 - 0
packages/core/src/api/schema/common/product-search.type.graphql

@@ -6,6 +6,7 @@ type SearchResponse {
     items: [SearchResult!]!
     totalItems: Int!
     facetValues: [FacetValueResult!]!
+    collections: [CollectionResult!]!
 }
 
 """
@@ -17,6 +18,15 @@ type FacetValueResult {
     count: Int!
 }
 
+"""
+Which Collections are present in the products returned
+by the search, and in what quantity.
+"""
+type CollectionResult {
+    collection: Collection!
+    count: Int!
+}
+
 type SearchResultAsset {
     id: ID!
     preview: String!

+ 20 - 3
packages/core/src/plugin/default-search-plugin/fulltext-search.resolver.ts

@@ -11,7 +11,7 @@ import { RequestContext } from '../../api/common/request-context';
 import { Allow } from '../../api/decorators/allow.decorator';
 import { Ctx } from '../../api/decorators/request-context.decorator';
 import { SearchResolver as BaseSearchResolver } from '../../api/resolvers/admin/search.resolver';
-import { FacetValue } from '../../entity';
+import { Collection, FacetValue } from '../../entity';
 
 import { FulltextSearchService } from './fulltext-search.service';
 
@@ -24,7 +24,7 @@ export class ShopFulltextSearchResolver implements Omit<BaseSearchResolver, 'rei
     async search(
         @Ctx() ctx: RequestContext,
         @Args() args: QuerySearchArgs,
-    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+    ): Promise<Omit<SearchResponse, 'facetValues' | 'collections'>> {
         const result = await this.fulltextSearchService.search(ctx, args.input, true);
         // ensure the facetValues property resolver has access to the input args
         (result as any).input = args.input;
@@ -39,6 +39,15 @@ export class ShopFulltextSearchResolver implements Omit<BaseSearchResolver, 'rei
         const facetValues = await this.fulltextSearchService.facetValues(ctx, parent.input, true);
         return facetValues.filter(i => !i.facetValue.facet.isPrivate);
     }
+
+    @ResolveField()
+    async collections(
+        @Ctx() ctx: RequestContext,
+        @Parent() parent: { input: SearchInput },
+    ): Promise<Array<{ collection: Collection; count: number }>> {
+        const collections = await this.fulltextSearchService.collections(ctx, parent.input, true);
+        return collections.filter(i => !i.collection.isPrivate);
+    }
 }
 
 @Resolver('SearchResponse')
@@ -50,7 +59,7 @@ export class AdminFulltextSearchResolver implements BaseSearchResolver {
     async search(
         @Ctx() ctx: RequestContext,
         @Args() args: QuerySearchArgs,
-    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+    ): Promise<Omit<SearchResponse, 'facetValues'| 'collections'>> {
         const result = await this.fulltextSearchService.search(ctx, args.input, false);
         // ensure the facetValues property resolver has access to the input args
         (result as any).input = args.input;
@@ -65,6 +74,14 @@ export class AdminFulltextSearchResolver implements BaseSearchResolver {
         return this.fulltextSearchService.facetValues(ctx, parent.input, false);
     }
 
+    @ResolveField()
+    async collections(
+        @Ctx() ctx: RequestContext,
+        @Parent() parent: { input: SearchInput },
+    ): Promise<Array<{ collection: Collection; count: number }>> {
+        return this.fulltextSearchService.collections(ctx, parent.input, false);
+    }
+
     @Mutation()
     @Allow(Permission.UpdateCatalog, Permission.UpdateProduct)
     async reindex(@Ctx() ctx: RequestContext) {

+ 22 - 2
packages/core/src/plugin/default-search-plugin/fulltext-search.service.ts

@@ -4,10 +4,11 @@ import { Omit } from '@vendure/common/lib/omit';
 
 import { RequestContext } from '../../api/common/request-context';
 import { InternalServerError } from '../../common/error/errors';
-import { FacetValue } from '../../entity';
+import { Collection, FacetValue } from '../../entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { Job } from '../../job-queue/job';
 import { FacetValueService } from '../../service/services/facet-value.service';
+import { CollectionService } from '../../service/services/collection.service';
 import { ProductVariantService } from '../../service/services/product-variant.service';
 import { SearchService } from '../../service/services/search.service';
 import { TransactionalConnection } from '../../service/transaction/transactional-connection';
@@ -31,6 +32,7 @@ export class FulltextSearchService {
         private connection: TransactionalConnection,
         private eventBus: EventBus,
         private facetValueService: FacetValueService,
+        private collectionService: CollectionService,
         private productVariantService: ProductVariantService,
         private searchIndexService: SearchIndexService,
         private searchService: SearchService,
@@ -46,7 +48,7 @@ export class FulltextSearchService {
         ctx: RequestContext,
         input: SearchInput,
         enabledOnly: boolean = false,
-    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+    ): Promise<Omit<Omit<SearchResponse, 'facetValues'>,'collections'>> {
         const items = await this.searchStrategy.getSearchResults(ctx, input, enabledOnly);
         const totalItems = await this.searchStrategy.getTotalCount(ctx, input, enabledOnly);
         return {
@@ -73,6 +75,24 @@ export class FulltextSearchService {
         });
     }
 
+    /**
+     * Return a list of all Collections which appear in the result set.
+     */
+    async collections(
+        ctx: RequestContext,
+        input: SearchInput,
+        enabledOnly: boolean = false,
+    ): Promise<Array<{ collection: Collection; count: number }>> {
+        const collectionIdsMap = await this.searchStrategy.getCollectionIds(ctx, input, enabledOnly);
+        const collections = await this.collectionService.findByIds(ctx, Array.from(collectionIdsMap.keys()));
+        return collections.map((collection, index) => {
+            return {
+                collection,
+                count: collectionIdsMap.get(collection.id.toString()) as number,
+            };
+        });
+    }
+
     /**
      * Rebuilds the full search index.
      */

+ 23 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -9,7 +9,7 @@ import { SearchIndexItem } from '../search-index-item.entity';
 
 import { SearchStrategy } from './search-strategy';
 import { fieldsToSelect } from './search-strategy-common';
-import { createFacetIdCountMap, mapToSearchResult } from './search-strategy-utils';
+import { createCollectionIdCountMap, createFacetIdCountMap, mapToSearchResult } from './search-strategy-utils';
 
 /**
  * A weighted fulltext search for MySQL / MariaDB.
@@ -41,6 +41,28 @@ export class MysqlSearchStrategy implements SearchStrategy {
         return createFacetIdCountMap(facetValuesResult);
     }
 
+    async getCollectionIds(
+        ctx: RequestContext,
+        input: SearchInput,
+        enabledOnly: boolean,
+    ): Promise<Map<ID, number>> {
+        const collectionsQb = this.connection
+            .getRepository(SearchIndexItem)
+            .createQueryBuilder('si')
+            .select(['MIN(productId)', 'MIN(productVariantId)'])
+            .addSelect('GROUP_CONCAT(collectionIds)', 'collections');
+
+        this.applyTermAndFilters(ctx, collectionsQb, input);
+        if (!input.groupByProduct) {
+            collectionsQb.groupBy('productVariantId');
+        }
+        if (enabledOnly) {
+            collectionsQb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
+        const collectionsResult = await collectionsQb.getRawMany();
+        return createCollectionIdCountMap(collectionsResult);
+    }
+
     async getSearchResults(
         ctx: RequestContext,
         input: SearchInput,

+ 23 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -9,7 +9,7 @@ import { SearchIndexItem } from '../search-index-item.entity';
 
 import { SearchStrategy } from './search-strategy';
 import { fieldsToSelect } from './search-strategy-common';
-import { createFacetIdCountMap, mapToSearchResult } from './search-strategy-utils';
+import { createCollectionIdCountMap, createFacetIdCountMap, mapToSearchResult } from './search-strategy-utils';
 
 /**
  * A weighted fulltext search for PostgeSQL.
@@ -41,6 +41,28 @@ export class PostgresSearchStrategy implements SearchStrategy {
         return createFacetIdCountMap(facetValuesResult);
     }
 
+    async getCollectionIds(
+        ctx: RequestContext,
+        input: SearchInput,
+        enabledOnly: boolean,
+    ): Promise<Map<ID, number>> {
+        const collectionsQb = this.connection
+            .getRepository(SearchIndexItem)
+            .createQueryBuilder('si')
+            .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
+            .addSelect(`string_agg("si"."collectionIds",',')`, 'collections');
+
+        this.applyTermAndFilters(ctx, collectionsQb, input, true);
+        if (!input.groupByProduct) {
+            collectionsQb.groupBy('"si"."productVariantId", "si"."productId"');
+        }
+        if (enabledOnly) {
+            collectionsQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
+        }
+        const collectionsResult = await collectionsQb.getRawMany();
+        return createCollectionIdCountMap(collectionsResult);
+    }
+
     async getSearchResults(
         ctx: RequestContext,
         input: SearchInput,

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

@@ -77,6 +77,23 @@ export function createFacetIdCountMap(facetValuesResult: Array<{ facetValues: st
     return result;
 }
 
+/**
+ * Given the raw query results containing rows with a `collections` property line "1,2,1,2",
+ * this function returns a map of Collection ids => count of how many times they occur.
+ */
+export function createCollectionIdCountMap(collectionsResult: Array<{ collections: string }>) {
+    const result = new Map<ID, number>();
+    for (const res of collectionsResult) {
+        const collectionIds: ID[] = unique(res.collections.split(',').filter(x => x !== ''));
+        for (const id of collectionIds) {
+            const count = result.get(id);
+            const newCount = count ? count + 1 : 1;
+            result.set(id, newCount);
+        }
+    }
+    return result;
+}
+
 function parseFocalPoint(focalPoint: any): Coordinate | undefined {
     if (focalPoint && typeof focalPoint === 'string') {
         try {

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

@@ -15,4 +15,9 @@ export interface SearchStrategy {
      * facetValue occurs in the result set.
      */
     getFacetValueIds(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<Map<ID, number>>;
+    /**
+     * Returns a map of `collectionId` => `count`, providing the number of times that
+     * collection occurs in the result set.
+     */
+    getCollectionIds(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<Map<ID, number>>;
 }

+ 23 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -8,7 +8,7 @@ import { TransactionalConnection } from '../../../service/transaction/transactio
 import { SearchIndexItem } from '../search-index-item.entity';
 
 import { SearchStrategy } from './search-strategy';
-import { createFacetIdCountMap, mapToSearchResult } from './search-strategy-utils';
+import { createCollectionIdCountMap, createFacetIdCountMap, mapToSearchResult } from './search-strategy-utils';
 
 /**
  * A rather naive search for SQLite / SQL.js. Rather than proper
@@ -41,6 +41,28 @@ export class SqliteSearchStrategy implements SearchStrategy {
         return createFacetIdCountMap(facetValuesResult);
     }
 
+    async getCollectionIds(
+        ctx: RequestContext,
+        input: SearchInput,
+        enabledOnly: boolean,
+    ): Promise<Map<ID, number>> {
+        const collectionsQb = this.connection
+            .getRepository(SearchIndexItem)
+            .createQueryBuilder('si')
+            .select(['productId', 'productVariantId'])
+            .addSelect('GROUP_CONCAT(si.collectionIds)', 'collections');
+
+        this.applyTermAndFilters(ctx, collectionsQb, input);
+        if (!input.groupByProduct) {
+            collectionsQb.groupBy('productVariantId');
+        }
+        if (enabledOnly) {
+            collectionsQb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
+        const collectionsResult = await collectionsQb.getRawMany();
+        return createCollectionIdCountMap(collectionsResult);
+    }
+
     async getSearchResults(
         ctx: RequestContext,
         input: SearchInput,

+ 12 - 0
packages/core/src/service/services/collection.service.ts

@@ -42,6 +42,7 @@ import { TransactionalConnection } from '../transaction/transactional-connection
 import { AssetService } from './asset.service';
 import { ChannelService } from './channel.service';
 import { FacetValueService } from './facet-value.service';
+import { FacetValue } from '../../entity';
 
 type ApplyCollectionFiltersJobData = { ctx: SerializedRequestContext; collectionIds: ID[] };
 
@@ -152,6 +153,17 @@ export class CollectionService implements OnModuleInit {
         return translateDeep(collection, ctx.languageCode, ['parent']);
     }
 
+    async findByIds(ctx: RequestContext, ids: ID[]): Promise<Array<Translated<Collection>>> {
+        const relations = ['featuredAsset', 'assets', 'channels', 'parent'];
+        const collections = this.connection.findByIdsInChannel(ctx, Collection, ids, ctx.channelId, {
+            relations,
+            loadEagerRelations: true,
+        });
+        return collections.then(values =>
+            values.map(collection => translateDeep(collection, ctx.languageCode, ['parent'])),
+        );
+    }
+
     async findOneBySlug(ctx: RequestContext, slug: string): Promise<Translated<Collection> | undefined> {
         const translations = await this.connection.getRepository(ctx, CollectionTranslation).find({
             relations: ['base'],

+ 45 - 1
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -30,6 +30,7 @@ import {
     LanguageCode,
     RemoveProductsFromChannel,
     RemoveProductVariantsFromChannel,
+    SearchCollections,
     SearchFacetValues,
     SearchGetPrices,
     SearchInput,
@@ -37,7 +38,7 @@ import {
     UpdateCollection,
     UpdateProduct,
     UpdateProductVariants,
-    UpdateTaxRate,
+    UpdateTaxRate
 } from '../../core/e2e/graphql/generated-e2e-admin-types';
 import { SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types';
 import {
@@ -266,6 +267,34 @@ describe('Elasticsearch plugin', () => {
             ]);
         });
 
+        it('returns correct collections when not grouped by product', async () => {
+            const result = await shopClient.query<SearchCollections.Query, SearchCollections.Variables>(
+                SEARCH_GET_COLLECTIONS,
+                {
+                    input: {
+                        groupByProduct: false,
+                    },
+                },
+            );
+            expect(result.search.collections).toEqual([
+                {collection: {id: 'T_2', name: 'Plants',},count: 3,},
+        ]);
+        });
+
+        it('returns correct collections when grouped by product', async () => {
+            const result = await shopClient.query<SearchCollections.Query, SearchCollections.Variables>(
+                SEARCH_GET_COLLECTIONS,
+                {
+                    input: {
+                        groupByProduct: true,
+                    },
+                },
+            );
+            expect(result.search.collections).toEqual([
+                {collection: {id: 'T_2', name: 'Plants',},count: 3,},
+            ]);
+        });
+
         it('encodes the productId and productVariantId', async () => {
             const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
                 SEARCH_PRODUCTS_SHOP,
@@ -1264,6 +1293,21 @@ export const SEARCH_GET_FACET_VALUES = gql`
     }
 `;
 
+export const SEARCH_GET_COLLECTIONS = gql`
+    query SearchCollections($input: SearchInput!) {
+        search(input: $input) {
+            totalItems
+            collections {
+                count
+                collection {
+                    id
+                    name
+                }
+            }
+        }
+    }
+`;
+
 export const SEARCH_GET_PRICES = gql`
     query SearchGetPrices($input: SearchInput!) {
         search(input: $input) {

Datei-Diff unterdrückt, da er zu groß ist
+ 665 - 518
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts


+ 16 - 7
packages/elasticsearch-plugin/src/elasticsearch-resolver.ts

@@ -6,13 +6,13 @@ import {
     SearchResponse,
 } from '@vendure/common/lib/generated-types';
 import { Omit } from '@vendure/common/lib/omit';
-import { Allow, Ctx, FacetValue, RequestContext, SearchResolver } from '@vendure/core';
+import { Allow, Collection, Ctx, FacetValue, RequestContext, SearchResolver } from '@vendure/core';
 
 import { ElasticsearchService } from './elasticsearch.service';
 import { ElasticSearchInput, SearchPriceData } from './types';
 
 @Resolver('SearchResponse')
-export class ShopElasticSearchResolver implements Omit<SearchResolver, 'facetValues' | 'reindex'> {
+export class ShopElasticSearchResolver implements Omit<SearchResolver, 'facetValues' | 'collections' | 'reindex' > {
     constructor(private elasticsearchService: ElasticsearchService) {}
 
     @Query()
@@ -20,7 +20,7 @@ export class ShopElasticSearchResolver implements Omit<SearchResolver, 'facetVal
     async search(
         @Ctx() ctx: RequestContext,
         @Args() args: QuerySearchArgs,
-    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+    ): Promise<Omit<SearchResponse, 'facetValues' | 'collections'>> {
         const result = await this.elasticsearchService.search(ctx, args.input, true);
         // ensure the facetValues property resolver has access to the input args
         (result as any).input = args.input;
@@ -37,7 +37,7 @@ export class ShopElasticSearchResolver implements Omit<SearchResolver, 'facetVal
 }
 
 @Resolver('SearchResponse')
-export class AdminElasticSearchResolver implements Omit<SearchResolver, 'facetValues'> {
+export class AdminElasticSearchResolver implements Omit<SearchResolver, 'facetValues' | 'collections'> {
     constructor(private elasticsearchService: ElasticsearchService) {}
 
     @Query()
@@ -45,7 +45,7 @@ export class AdminElasticSearchResolver implements Omit<SearchResolver, 'facetVa
     async search(
         @Ctx() ctx: RequestContext,
         @Args() args: QuerySearchArgs,
-    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+    ): Promise<Omit<SearchResponse, 'facetValues' | 'collections'>> {
         const result = await this.elasticsearchService.search(ctx, args.input, false);
         // ensure the facetValues property resolver has access to the input args
         (result as any).input = args.input;
@@ -60,15 +60,24 @@ export class AdminElasticSearchResolver implements Omit<SearchResolver, 'facetVa
 }
 
 @Resolver('SearchResponse')
-export class EntityElasticSearchResolver implements Pick<SearchResolver, 'facetValues'> {
+export class EntityElasticSearchResolver implements Pick<SearchResolver, 'facetValues' | 'collections'> {
     constructor(private elasticsearchService: ElasticsearchService) {}
 
     @ResolveField()
     async facetValues(
         @Ctx() ctx: RequestContext,
-        @Parent() parent: Omit<SearchResponse, 'facetValues'>,
+        @Parent() parent: Omit<SearchResponse, 'facetValues' | 'collections'>,
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
         const facetValues = await this.elasticsearchService.facetValues(ctx, (parent as any).input, true);
         return facetValues.filter(i => !i.facetValue.facet.isPrivate);
     }
+
+    @ResolveField()
+    async collections(
+        @Ctx() ctx: RequestContext,
+        @Parent() parent: Omit<SearchResponse, 'facetValues' | 'collections'>,
+    ): Promise<Array<{ collection: Collection; count: number }>> {
+        const collections = await this.elasticsearchService.collections(ctx, (parent as any).input, true);
+        return collections.filter(i => !i.collection.isPrivate);
+    }
 }

+ 57 - 1
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -2,6 +2,8 @@ import { Client } from '@elastic/elasticsearch';
 import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
 import { SearchResult, SearchResultAsset } from '@vendure/common/lib/generated-types';
 import {
+    Collection,
+    CollectionService,
     ConfigService,
     DeepRequired,
     FacetValue,
@@ -39,6 +41,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         private elasticsearchIndexService: ElasticsearchIndexService,
         private configService: ConfigService,
         private facetValueService: FacetValueService,
+        private collectionService: CollectionService,
     ) {
         searchService.adopt(this);
     }
@@ -114,7 +117,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         ctx: RequestContext,
         input: ElasticSearchInput,
         enabledOnly: boolean = false,
-    ): Promise<Omit<ElasticSearchResponse, 'facetValues' | 'priceRange'>> {
+    ): Promise<Omit<ElasticSearchResponse, 'facetValues' | 'collections' | 'priceRange'>> {
         const { indexPrefix } = this.options;
         const { groupByProduct } = input;
         const elasticSearchBody = buildElasticBody(
@@ -208,6 +211,59 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
         });
     }
 
+    /**
+     * Return a list of all Collections which appear in the result set.
+     */
+    async collections(
+        ctx: RequestContext,
+        input: ElasticSearchInput,
+        enabledOnly: boolean = false,
+    ): Promise<Array<{ collection: Collection; count: number }>> {
+        const { indexPrefix } = this.options;
+        const elasticSearchBody = buildElasticBody(
+            input,
+            this.options.searchConfig,
+            ctx.channelId,
+            ctx.languageCode,
+            enabledOnly,
+        );
+        elasticSearchBody.from = 0;
+        elasticSearchBody.size = 0;
+        elasticSearchBody.aggs = {
+            collection: {
+                terms: {
+                    field: 'collectionIds',
+                    size: this.options.searchConfig.collectionMaxSize,
+                },
+            },
+        };
+        let body: SearchResponseBody<VariantIndexItem>;
+        try {
+            const result = await this.client.search<SearchResponseBody<VariantIndexItem>>({
+                index: indexPrefix + (input.groupByProduct ? PRODUCT_INDEX_NAME : VARIANT_INDEX_NAME),
+                body: elasticSearchBody,
+            });
+            body = result.body;
+        } catch (e) {
+            Logger.error(e.message, loggerCtx, e.stack);
+            throw e;
+        }
+
+        const buckets = body.aggregations ? body.aggregations.collection.buckets : [];
+
+        const collections = await this.collectionService.findByIds(
+            ctx,
+            buckets.map(b => b.key),
+        );
+        return collections.map(collection => {
+            const bucket = buckets.find(b => b.key.toString() === collection.id.toString());
+            return {
+                collection,
+                count: bucket ? bucket.doc_count : 0,
+            };
+        });
+    }
+
     async priceRange(ctx: RequestContext, input: ElasticSearchInput): Promise<SearchPriceData> {
         const { indexPrefix, searchConfig } = this.options;
         const { groupByProduct } = input;

+ 11 - 0
packages/elasticsearch-plugin/src/options.ts

@@ -163,6 +163,16 @@ export interface SearchConfig {
      */
     facetValueMaxSize?: number;
 
+    /**
+     * @description
+     * The maximum number of Collections to return from the search query. Internally, this
+     * value sets the "size" property of an Elasticsearch aggregation.
+     *
+     * @default
+     * 50
+     */
+    collectionMaxSize?: number;
+
     // prettier-ignore
     /**
      * @description
@@ -315,6 +325,7 @@ export const defaultOptions: ElasticsearchRuntimeOptions = {
     batchSize: 2000,
     searchConfig: {
         facetValueMaxSize: 50,
+        collectionMaxSize: 50,
         multiMatchType: 'best_fields',
         boostFields: {
             productName: 1,

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
schema-admin.json


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
schema-shop.json


Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.