Просмотр исходного кода

feat(elasticsearch-plugin): Add facetFilters input for search query

Closes #726
Artem Danilov 4 лет назад
Родитель
Сommit
23cc655a07

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

@@ -2937,10 +2937,16 @@ export type DateOperators = {
   between?: Maybe<DateRange>;
 };
 
+export type FacetValueFilterInput = {
+  and?: Maybe<Scalars['ID']>;
+  or?: Maybe<Array<Scalars['ID']>>;
+};
+
 export type SearchInput = {
   term?: Maybe<Scalars['String']>;
   facetValueIds?: Maybe<Array<Scalars['ID']>>;
   facetValueOperator?: Maybe<LogicalOperator>;
+  facetValueFilters?: Maybe<Array<FacetValueFilterInput>>;
   collectionId?: Maybe<Scalars['ID']>;
   collectionSlug?: Maybe<Scalars['String']>;
   groupByProduct?: Maybe<Scalars['Boolean']>;

+ 6 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -2813,10 +2813,16 @@ export type DateOperators = {
   between?: Maybe<DateRange>;
 };
 
+export type FacetValueFilterInput = {
+  and?: Maybe<Scalars['ID']>;
+  or?: Maybe<Array<Scalars['ID']>>;
+};
+
 export type SearchInput = {
   term?: Maybe<Scalars['String']>;
   facetValueIds?: Maybe<Array<Scalars['ID']>>;
   facetValueOperator?: Maybe<LogicalOperator>;
+  facetValueFilters?: Maybe<Array<FacetValueFilterInput>>;
   collectionId?: Maybe<Scalars['ID']>;
   collectionSlug?: Maybe<Scalars['String']>;
   groupByProduct?: Maybe<Scalars['Boolean']>;

Разница между файлами не показана из-за своего большого размера
+ 551 - 582
packages/common/src/generated-shop-types.ts


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

@@ -2899,10 +2899,16 @@ export type DateOperators = {
   between?: Maybe<DateRange>;
 };
 
+export type FacetValueFilterInput = {
+  and?: Maybe<Scalars['ID']>;
+  or?: Maybe<Array<Scalars['ID']>>;
+};
+
 export type SearchInput = {
   term?: Maybe<Scalars['String']>;
   facetValueIds?: Maybe<Array<Scalars['ID']>>;
   facetValueOperator?: Maybe<LogicalOperator>;
+  facetValueFilters?: Maybe<Array<FacetValueFilterInput>>;
   collectionId?: Maybe<Scalars['ID']>;
   collectionSlug?: Maybe<Scalars['String']>;
   groupByProduct?: Maybe<Scalars['Boolean']>;

+ 162 - 44
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -235,6 +235,123 @@ describe('Default search plugin', () => {
         ]);
     }
 
+    async function testMatchFacetValueFiltersAnd(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    groupByProduct: true,
+                    facetValueFilters: [{ and: 'T_1' }, { and: 'T_2' }],
+                },
+            },
+        );
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Laptop',
+            'Curvy Monitor',
+            'Gaming PC',
+            'Hard Drive',
+            'Clacky Keyboard',
+            'USB Cable',
+        ]);
+    }
+
+    async function testMatchFacetValueFiltersOr(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    groupByProduct: true,
+                    facetValueFilters: [ { or: ['T_1', 'T_5'] }],
+                },
+            },
+        );
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Laptop',
+            'Curvy Monitor',
+            'Gaming PC',
+            'Hard Drive',
+            'Clacky Keyboard',
+            'USB Cable',
+            'Instant Camera',
+            'Camera Lens',
+            'Tripod',
+            'Slr Camera',
+            'Spiky Cactus',
+            'Orchid',
+            'Bonsai Tree',
+        ]);
+    }
+
+    async function testMatchFacetValueFiltersOrWithAnd(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    groupByProduct: true,
+                    facetValueFilters: [{and: 'T_1'}, { or: ['T_2', 'T_3'] }],
+                },
+            },
+        );
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Laptop',
+            'Curvy Monitor',
+            'Gaming PC',
+            'Hard Drive',
+            'Clacky Keyboard',
+            'USB Cable',
+            'Instant Camera',
+            'Camera Lens',
+            'Tripod',
+            'Slr Camera'
+        ]);
+    }
+
+    async function testMatchFacetValueFiltersWithFacetIdsOr(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    facetValueIds: ['T_2', 'T_3'],
+                    facetValueOperator: LogicalOperator.OR,
+                    facetValueFilters: [{and:'T_1'}],
+                    groupByProduct: true,
+                },
+            },
+        );
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Laptop',
+            'Curvy Monitor',
+            'Gaming PC',
+            'Hard Drive',
+            'Clacky Keyboard',
+            'USB Cable',
+            'Instant Camera',
+            'Camera Lens',
+            'Tripod',
+            'Slr Camera'
+        ]);
+    }
+
+    async function testMatchFacetValueFiltersWithFacetIdsAnd(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    facetValueIds: ['T_1'],
+                    facetValueFilters: [{and:'T_3'}],
+                    facetValueOperator: LogicalOperator.AND,
+                    groupByProduct: true,
+                },
+            },
+        );
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Instant Camera',
+            'Camera Lens',
+            'Tripod',
+            'Slr Camera'
+        ]);
+    }
+
     async function testMatchCollectionId(client: SimpleGraphQLClient) {
         const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
             SEARCH_PRODUCTS_SHOP,
@@ -332,6 +449,16 @@ describe('Default search plugin', () => {
 
         it('matches by facetId with OR operator', () => testMatchFacetIdsOr(shopClient));
 
+        it('matches by FacetValueFilters AND', () => testMatchFacetValueFiltersAnd(shopClient));
+
+        it('matches by FacetValueFilters OR', () => testMatchFacetValueFiltersOr(shopClient));
+
+        it('matches by FacetValueFilters OR and AND', () => testMatchFacetValueFiltersOrWithAnd(shopClient));
+
+        it('matches by FacetValueFilters with facetId OR operator', () => testMatchFacetValueFiltersWithFacetIdsOr(shopClient));
+
+        it('matches by FacetValueFilters with facetId AND operator', () => testMatchFacetValueFiltersWithFacetIdsAnd(shopClient));
+
         it('matches by collectionId', () => testMatchCollectionId(shopClient));
 
         it('matches by collectionSlug', () => testMatchCollectionSlug(shopClient));
@@ -494,6 +621,16 @@ describe('Default search plugin', () => {
 
         it('matches by facetId with OR operator', () => testMatchFacetIdsOr(adminClient));
 
+        it('matches by FacetValueFilters AND', () => testMatchFacetValueFiltersAnd(shopClient));
+
+        it('matches by FacetValueFilters OR', () => testMatchFacetValueFiltersOr(shopClient));
+
+        it('matches by FacetValueFilters OR and AND', () => testMatchFacetValueFiltersOrWithAnd(shopClient));
+
+        it('matches by FacetValueFilters with facetId OR operator', () => testMatchFacetValueFiltersWithFacetIdsOr(shopClient));
+
+        it('matches by FacetValueFilters with facetId AND operator', () => testMatchFacetValueFiltersWithFacetIdsAnd(shopClient));
+
         it('matches by collectionId', () => testMatchCollectionId(adminClient));
 
         it('matches by collectionSlug', () => testMatchCollectionSlug(adminClient));
@@ -657,10 +794,8 @@ describe('Default search plugin', () => {
             }, 10000);
 
             it('updates index when a Collection created', async () => {
-                const { createCollection } = await adminClient.query<
-                    CreateCollection.Mutation,
-                    CreateCollection.Variables
-                >(CREATE_COLLECTION, {
+                const { createCollection } = await adminClient.query<CreateCollection.Mutation,
+                    CreateCollection.Variables>(CREATE_COLLECTION, {
                     input: {
                         translations: [
                             {
@@ -793,10 +928,8 @@ describe('Default search plugin', () => {
                     groupByProduct: false,
                 });
 
-                const { deleteProductVariant } = await adminClient.query<
-                    DeleteProductVariant.Mutation,
-                    DeleteProductVariant.Variables
-                >(DELETE_PRODUCT_VARIANT, { id: s1.items[0].productVariantId });
+                const { deleteProductVariant } = await adminClient.query<DeleteProductVariant.Mutation,
+                    DeleteProductVariant.Variables>(DELETE_PRODUCT_VARIANT, { id: s1.items[0].productVariantId });
 
                 await awaitRunningJobs(adminClient);
 
@@ -892,10 +1025,8 @@ describe('Default search plugin', () => {
                     .map(() => Math.random().toString(36))
                     .join(' ');
 
-                const { createProduct } = await adminClient.query<
-                    CreateProduct.Mutation,
-                    CreateProduct.Variables
-                >(CREATE_PRODUCT, {
+                const { createProduct } = await adminClient.query<CreateProduct.Mutation,
+                    CreateProduct.Variables>(CREATE_PRODUCT, {
                     input: {
                         translations: [
                             {
@@ -927,6 +1058,9 @@ describe('Default search plugin', () => {
                 expect(result.search.items.map(i => i.productName)).toEqual([
                     'Very long description aabbccdd',
                 ]);
+                await adminClient.query<DeleteProduct.Mutation, DeleteProduct.Variables>(DELETE_PRODUCT, {
+                    id: createProduct.id,
+                });
             });
         });
 
@@ -935,10 +1069,8 @@ describe('Default search plugin', () => {
             let createdProductId: string;
 
             it('creates synthetic index item for Product with no variants', async () => {
-                const { createProduct } = await adminClient.query<
-                    CreateProduct.Mutation,
-                    CreateProduct.Variables
-                >(CREATE_PRODUCT, {
+                const { createProduct } = await adminClient.query<CreateProduct.Mutation,
+                    CreateProduct.Variables>(CREATE_PRODUCT, {
                     input: {
                         facetValueIds: ['T_1'],
                         translations: [
@@ -979,10 +1111,8 @@ describe('Default search plugin', () => {
             });
 
             it('removes synthetic index item once a variant is created', async () => {
-                const { createProductVariants } = await adminClient.query<
-                    CreateProductVariants.Mutation,
-                    CreateProductVariants.Variables
-                >(CREATE_PRODUCT_VARIANTS, {
+                const { createProductVariants } = await adminClient.query<CreateProductVariants.Mutation,
+                    CreateProductVariants.Variables>(CREATE_PRODUCT_VARIANTS, {
                     input: [
                         {
                             productId: createdProductId,
@@ -1008,10 +1138,8 @@ describe('Default search plugin', () => {
             let secondChannel: ChannelFragment;
 
             beforeAll(async () => {
-                const { createChannel } = await adminClient.query<
-                    CreateChannel.Mutation,
-                    CreateChannel.Variables
-                >(CREATE_CHANNEL, {
+                const { createChannel } = await adminClient.query<CreateChannel.Mutation,
+                    CreateChannel.Variables>(CREATE_CHANNEL, {
                     input: {
                         code: 'second-channel',
                         token: SECOND_CHANNEL_TOKEN,
@@ -1042,10 +1170,8 @@ describe('Default search plugin', () => {
 
             it('removing product from channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                const { removeProductsFromChannel } = await adminClient.query<
-                    RemoveProductsFromChannel.Mutation,
-                    RemoveProductsFromChannel.Variables
-                >(REMOVE_PRODUCT_FROM_CHANNEL, {
+                const { removeProductsFromChannel } = await adminClient.query<RemoveProductsFromChannel.Mutation,
+                    RemoveProductsFromChannel.Variables>(REMOVE_PRODUCT_FROM_CHANNEL, {
                     input: {
                         productIds: ['T_2'],
                         channelId: secondChannel.id,
@@ -1060,10 +1186,8 @@ describe('Default search plugin', () => {
 
             it('adding product variant to channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                await adminClient.query<
-                    AssignProductVariantsToChannel.Mutation,
-                    AssignProductVariantsToChannel.Variables
-                >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                await adminClient.query<AssignProductVariantsToChannel.Mutation,
+                    AssignProductVariantsToChannel.Variables>(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
                     input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] },
                 });
                 await awaitRunningJobs(adminClient);
@@ -1086,10 +1210,8 @@ describe('Default search plugin', () => {
 
             it('removing product variant from channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                await adminClient.query<
-                    RemoveProductVariantsFromChannel.Mutation,
-                    RemoveProductVariantsFromChannel.Variables
-                >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
+                await adminClient.query<RemoveProductVariantsFromChannel.Mutation,
+                    RemoveProductVariantsFromChannel.Variables>(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
                     input: { channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15'] },
                 });
                 await awaitRunningJobs(adminClient);
@@ -1110,10 +1232,8 @@ describe('Default search plugin', () => {
 
             it('updating product affects current channel', async () => {
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                const { updateProduct } = await adminClient.query<
-                    UpdateProduct.Mutation,
-                    UpdateProduct.Variables
-                >(UPDATE_PRODUCT, {
+                const { updateProduct } = await adminClient.query<UpdateProduct.Mutation,
+                    UpdateProduct.Variables>(UPDATE_PRODUCT, {
                     input: {
                         id: 'T_3',
                         enabled: true,
@@ -1156,10 +1276,8 @@ describe('Default search plugin', () => {
             }
 
             beforeAll(async () => {
-                const { updateProduct } = await adminClient.query<
-                    UpdateProduct.Mutation,
-                    UpdateProduct.Variables
-                >(UPDATE_PRODUCT, {
+                const { updateProduct } = await adminClient.query<UpdateProduct.Mutation,
+                    UpdateProduct.Variables>(UPDATE_PRODUCT, {
                     input: {
                         id: 'T_1',
                         translations: [

+ 6 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -2813,10 +2813,16 @@ export type DateOperators = {
   between?: Maybe<DateRange>;
 };
 
+export type FacetValueFilterInput = {
+  and?: Maybe<Scalars['ID']>;
+  or?: Maybe<Array<Scalars['ID']>>;
+};
+
 export type SearchInput = {
   term?: Maybe<Scalars['String']>;
   facetValueIds?: Maybe<Array<Scalars['ID']>>;
   facetValueOperator?: Maybe<LogicalOperator>;
+  facetValueFilters?: Maybe<Array<FacetValueFilterInput>>;
   collectionId?: Maybe<Scalars['ID']>;
   collectionSlug?: Maybe<Scalars['String']>;
   groupByProduct?: Maybe<Scalars['Boolean']>;

Разница между файлами не показана из-за своего большого размера
+ 517 - 548
packages/core/e2e/graphql/generated-e2e-shop-types.ts


+ 8 - 2
packages/core/src/api/schema/common/common-types.graphql

@@ -113,10 +113,16 @@ input DateOperators {
     between: DateRange
 }
 
+input facetValueFilterInput {
+    and: ID
+    or: [ID!]
+}
+
 input SearchInput {
     term: String
-    facetValueIds: [ID!]
-    facetValueOperator: LogicalOperator
+    facetValueIds: [ID!] @deprecated(reason: "Use `facetValueFilters` instead")
+    facetValueOperator: LogicalOperator @deprecated(reason: "Use `facetValueFilters` instead")
+    facetValueFilters: [facetValueFilterInput!]
     collectionId: ID
     collectionSlug: String
     groupByProduct: Boolean

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

@@ -108,7 +108,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
         qb: SelectQueryBuilder<SearchIndexItem>,
         input: SearchInput,
     ): SelectQueryBuilder<SearchIndexItem> {
-        const { term, facetValueIds, facetValueOperator, collectionId, collectionSlug } = input;
+        const { term, facetValueFilters, facetValueIds, facetValueOperator, collectionId, collectionSlug } = input;
 
         if (term && term.length > this.minTermLength) {
             const termScoreQuery = this.connection
@@ -158,6 +158,35 @@ export class MysqlSearchStrategy implements SearchStrategy {
                 }),
             );
         }
+        if (facetValueFilters?.length) {
+            qb.andWhere(
+                new Brackets(qb1 => {
+                    for (const facetValueFilter of facetValueFilters)
+                    {
+                        qb1.andWhere(new Brackets(qb2 => {
+                            if (facetValueFilter.and && facetValueFilter.or && facetValueFilter.or.length) {
+                                throw Error('facetValueId and facetValueIds cannot be specified simultaneously');
+                            }
+                            if (facetValueFilter.and) {
+                                const placeholder = '_' + facetValueFilter.and;
+                                const clause = `FIND_IN_SET(:${placeholder}, facetValueIds)`;
+                                const params = { [placeholder]: facetValueFilter.and };
+                                qb2.where(clause, params);
+                            }
+                            if (facetValueFilter.or && facetValueFilter.or.length) {
+                                for (const id of facetValueFilter.or)
+                                {
+                                    const placeholder = '_' + id;
+                                    const clause = `FIND_IN_SET(:${placeholder}, facetValueIds)`;
+                                    const params = { [placeholder]: id };
+                                    qb2.orWhere(clause, params);
+                                }
+                            }
+                        }))
+                    }
+                }),
+            );
+        }
         if (collectionId) {
             qb.andWhere(`FIND_IN_SET (:collectionId, collectionIds)`, { collectionId });
         }

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

@@ -111,7 +111,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
         input: SearchInput,
         forceGroup: boolean = false,
     ): SelectQueryBuilder<SearchIndexItem> {
-        const { term, facetValueIds, facetValueOperator, collectionId, collectionSlug } = input;
+        const { term, facetValueFilters, facetValueIds, facetValueOperator, collectionId, collectionSlug } = input;
         // join multiple words with the logical AND operator
         const termLogicalAnd = term ? term.trim().replace(/\s+/g, ' & ') : '';
 
@@ -156,6 +156,35 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 }),
             );
         }
+        if (facetValueFilters?.length) {
+            qb.andWhere(
+                new Brackets(qb1 => {
+                    for (const facetValueFilter of facetValueFilters)
+                    {
+                        qb1.andWhere(new Brackets(qb2 => {
+                            if (facetValueFilter.and && facetValueFilter.or && facetValueFilter.or.length) {
+                                throw Error('facetValueId and facetValueIds cannot be specified simultaneously');
+                            }
+                            if (facetValueFilter.and) {
+                                const placeholder = '_' + facetValueFilter.and;
+                                const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
+                                const params = { [placeholder]: facetValueFilter.and };
+                                qb2.where(clause, params);
+                            }
+                            if (facetValueFilter.or && facetValueFilter.or.length) {
+                                for (const id of facetValueFilter.or)
+                                {
+                                    const placeholder = '_' + id;
+                                    const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
+                                    const params = { [placeholder]: id };
+                                    qb2.orWhere(clause, params);
+                                }
+                            }
+                        }))
+                    }
+                }),
+            );
+        }
         if (collectionId) {
             qb.andWhere(`:collectionId = ANY (string_to_array(si.collectionIds, ','))`, { collectionId });
         }

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

@@ -105,7 +105,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
         qb: SelectQueryBuilder<SearchIndexItem>,
         input: SearchInput,
     ): SelectQueryBuilder<SearchIndexItem> {
-        const { term, facetValueIds, facetValueOperator, collectionId, collectionSlug } = input;
+        const { term, facetValueFilters, facetValueIds, facetValueOperator, collectionId, collectionSlug } = input;
 
         qb.where('1 = 1');
         if (term && term.length > this.minTermLength) {
@@ -145,6 +145,35 @@ export class SqliteSearchStrategy implements SearchStrategy {
                 }),
             );
         }
+        if (facetValueFilters?.length) {
+            qb.andWhere(
+                new Brackets(qb1 => {
+                    for (const facetValueFilter of facetValueFilters)
+                    {
+                        qb1.andWhere(new Brackets(qb2 => {
+                            if (facetValueFilter.and && facetValueFilter.or && facetValueFilter.or.length) {
+                                throw Error('facetValueId and facetValueIds cannot be specified simultaneously');
+                            }
+                            if (facetValueFilter.and) {
+                                const placeholder = '_' + facetValueFilter.and;
+                                const clause = `(',' || facetValueIds || ',') LIKE :${placeholder}`;
+                                const params = { [placeholder]: `%,${facetValueFilter.and},%` }
+                                qb2.where(clause, params);
+                            }
+                            if (facetValueFilter.or && facetValueFilter.or.length) {
+                                for (const id of facetValueFilter.or)
+                                {
+                                    const placeholder = '_' + id;
+                                    const clause = `(',' || facetValueIds || ',') LIKE :${placeholder}`;
+                                    const params = { [placeholder]: `%,${id},%` };
+                                    qb2.orWhere(clause, params);
+                                }
+                            }
+                        }))
+                    }
+                }),
+            );
+        }
         if (collectionId) {
             qb.andWhere(`(',' || collectionIds || ',') LIKE :collectionId`, {
                 collectionId: `%,${collectionId},%`,

+ 121 - 0
packages/elasticsearch-plugin/e2e/e2e-helpers.ts

@@ -116,6 +116,127 @@ export async function testMatchFacetIdsOr(client: SimpleGraphQLClient) {
     ]);
 }
 
+export async function testMatchFacetValueFiltersAnd(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                groupByProduct: true,
+                facetValueFilters: [{ and: 'T_1' }, { and: 'T_2' }],
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName).sort()).toEqual([
+        'Laptop',
+        'Curvy Monitor',
+        'Gaming PC',
+        'Hard Drive',
+        'Clacky Keyboard',
+        'USB Cable',
+    ].sort());
+}
+
+export async function testMatchFacetValueFiltersOr(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                groupByProduct: true,
+                facetValueFilters: [ { or: ['T_1', 'T_5'] }],
+                sort: {
+                    name: SortOrder.ASC,
+                },
+                take: 20,
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName).sort()).toEqual([
+        'Bonsai Tree',
+        'Camera Lens',
+        'Clacky Keyboard',
+        'Curvy Monitor',
+        'Gaming PC',
+        'Hard Drive',
+        'Instant Camera',
+        'Laptop',
+        'Orchid',
+        'SLR Camera',
+        'Spiky Cactus',
+        'Tripod',
+        'USB Cable',
+    ].sort());
+}
+
+export async function testMatchFacetValueFiltersOrWithAnd(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                groupByProduct: true,
+                facetValueFilters: [{and: 'T_1'}, { or: ['T_2', 'T_3'] }],
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName).sort()).toEqual([
+        'Laptop',
+        'Curvy Monitor',
+        'Gaming PC',
+        'Hard Drive',
+        'Clacky Keyboard',
+        'USB Cable',
+        'Instant Camera',
+        'Camera Lens',
+        'Tripod',
+        'SLR Camera'
+    ].sort());
+}
+
+export async function testMatchFacetValueFiltersWithFacetIdsOr(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                facetValueIds: ['T_2', 'T_3'],
+                facetValueOperator: LogicalOperator.OR,
+                facetValueFilters: [{and:'T_1'}],
+                groupByProduct: true,
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName).sort()).toEqual([
+        'Laptop',
+        'Curvy Monitor',
+        'Gaming PC',
+        'Hard Drive',
+        'Clacky Keyboard',
+        'USB Cable',
+        'Instant Camera',
+        'Camera Lens',
+        'Tripod',
+        'SLR Camera',
+    ].sort());
+}
+
+export async function testMatchFacetValueFiltersWithFacetIdsAnd(client: SimpleGraphQLClient) {
+    const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+        SEARCH_PRODUCTS_SHOP,
+        {
+            input: {
+                facetValueIds: ['T_1'],
+                facetValueFilters: [{and:'T_3'}],
+                facetValueOperator: LogicalOperator.AND,
+                groupByProduct: true,
+            },
+        },
+    );
+    expect(result.search.items.map(i => i.productName).sort()).toEqual([
+        'Instant Camera',
+        'Camera Lens',
+        'Tripod',
+        'SLR Camera'
+    ].sort());
+}
+
 export async function testMatchCollectionId(client: SimpleGraphQLClient) {
     const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
         SEARCH_PRODUCTS_SHOP,

+ 25 - 0
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -71,6 +71,11 @@ import {
     testMatchCollectionSlug,
     testMatchFacetIdsAnd,
     testMatchFacetIdsOr,
+    testMatchFacetValueFiltersAnd,
+    testMatchFacetValueFiltersOr,
+    testMatchFacetValueFiltersOrWithAnd,
+    testMatchFacetValueFiltersWithFacetIdsAnd,
+    testMatchFacetValueFiltersWithFacetIdsOr,
     testMatchSearchTerm,
     testNoGrouping,
     testPriceRanges,
@@ -158,6 +163,16 @@ describe('Elasticsearch plugin', () => {
 
         it('matches by facetValueId with OR operator', () => testMatchFacetIdsOr(shopClient));
 
+        it('matches by FacetValueFilters AND', () => testMatchFacetValueFiltersAnd(shopClient));
+
+        it('matches by FacetValueFilters OR', () => testMatchFacetValueFiltersOr(shopClient));
+
+        it('matches by FacetValueFilters OR and AND', () => testMatchFacetValueFiltersOrWithAnd(shopClient));
+
+        it('matches by FacetValueFilters with facetId OR operator', () => testMatchFacetValueFiltersWithFacetIdsOr(shopClient));
+
+        it('matches by FacetValueFilters with facetId AND operator', () => testMatchFacetValueFiltersWithFacetIdsAnd(shopClient));
+
         it('matches by collectionId', () => testMatchCollectionId(shopClient));
 
         it('matches by collectionSlug', () => testMatchCollectionSlug(shopClient));
@@ -312,6 +327,16 @@ describe('Elasticsearch plugin', () => {
 
         it('matches by facetValueId with OR operator', () => testMatchFacetIdsOr(adminClient));
 
+        it('matches by FacetValueFilters AND', () => testMatchFacetValueFiltersAnd(shopClient));
+
+        it('matches by FacetValueFilters OR', () => testMatchFacetValueFiltersOr(shopClient));
+
+        it('matches by FacetValueFilters OR and AND', () => testMatchFacetValueFiltersOrWithAnd(shopClient));
+
+        it('matches by FacetValueFilters with facetId OR operator', () => testMatchFacetValueFiltersWithFacetIdsOr(shopClient));
+
+        it('matches by FacetValueFilters with facetId AND operator', () => testMatchFacetValueFiltersWithFacetIdsAnd(shopClient));
+
         it('matches by collectionId', () => testMatchCollectionId(adminClient));
 
         it('matches by collectionSlug', () => testMatchCollectionSlug(adminClient));

+ 6 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -2813,10 +2813,16 @@ export type DateOperators = {
   between?: Maybe<DateRange>;
 };
 
+export type FacetValueFilterInput = {
+  and?: Maybe<Scalars['ID']>;
+  or?: Maybe<Array<Scalars['ID']>>;
+};
+
 export type SearchInput = {
   term?: Maybe<Scalars['String']>;
   facetValueIds?: Maybe<Array<Scalars['ID']>>;
   facetValueOperator?: Maybe<LogicalOperator>;
+  facetValueFilters?: Maybe<Array<FacetValueFilterInput>>;
   collectionId?: Maybe<Scalars['ID']>;
   collectionSlug?: Maybe<Scalars['String']>;
   groupByProduct?: Maybe<Scalars['Boolean']>;

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

@@ -72,6 +72,134 @@ describe('buildElasticBody()', () => {
         });
     });
 
+    it('facetValueFilters AND with OR', () => {
+        const result = buildElasticBody(
+            { facetValueFilters: [{ and: '1' }, { or: ['2', '3'] }] },
+            searchConfig,
+            CHANNEL_ID,
+            LanguageCode.en,
+        );
+        expect(result.query).toEqual({
+            bool: {
+                filter: [
+                    CHANNEL_ID_TERM,
+                    LANGUAGE_CODE_TERM,
+                    { term: { facetValueIds: '1' } },
+                    {
+                        bool: {
+                            should: [{ term: { facetValueIds: '2' } }, { term: { facetValueIds: '3' } }],
+                        },
+                    },
+                ],
+            },
+        });
+    });
+
+    it('facetValueFilters AND', () => {
+        const result = buildElasticBody(
+            { facetValueFilters: [{ and: '1' }, { and: '2' }] },
+            searchConfig,
+            CHANNEL_ID,
+            LanguageCode.en,
+        );
+        expect(result.query).toEqual({
+            bool: {
+                filter: [
+                    CHANNEL_ID_TERM,
+                    LANGUAGE_CODE_TERM,
+                    { term: { facetValueIds: '1' } },
+                    { term: { facetValueIds: '2' } },
+                ],
+            },
+        });
+    });
+
+    it('facetValueFilters OR', () => {
+        const result = buildElasticBody(
+            { facetValueFilters: [ { or: ['1', '2'] }] },
+            searchConfig,
+            CHANNEL_ID,
+            LanguageCode.en,
+        );
+        expect(result.query).toEqual({
+            bool: {
+                filter: [
+                    CHANNEL_ID_TERM,
+                    LANGUAGE_CODE_TERM,
+                    {
+                        bool: {
+                            should: [{ term: { facetValueIds: '1' } }, { term: { facetValueIds: '2' } }],
+                        },
+                    },
+                ],
+            },
+        });
+    });
+
+    it('facetValueFilters with facetValueIds AND', () => {
+        const result = buildElasticBody(
+            {
+                facetValueFilters: [{ and: '1' }, { or: ['2', '3'] }],
+                facetValueIds: ['1', '2'],
+                facetValueOperator: LogicalOperator.AND,
+            },
+            searchConfig,
+            CHANNEL_ID,
+            LanguageCode.en,
+        );
+        expect(result.query).toEqual({
+            bool: {
+                filter: [
+                    CHANNEL_ID_TERM,
+                    LANGUAGE_CODE_TERM,
+                    {
+                        bool: {
+                            must: [{ term: { facetValueIds: '1' } }, { term: { facetValueIds: '2' } }],
+                        },
+                    },
+                    { term: { facetValueIds: '1' } },
+                    {
+                        bool: {
+                            should: [{ term: { facetValueIds: '2' } }, { term: { facetValueIds: '3' } }],
+                        },
+                    },
+                ],
+            },
+        });
+    });
+
+    it('facetValueFilters with facetValueIds OR', () => {
+        const result = buildElasticBody(
+            {
+                facetValueFilters: [{ and: '1' }, { or: ['2', '3'] }],
+                facetValueIds: ['1', '2'],
+                facetValueOperator: LogicalOperator.OR,
+            },
+            searchConfig,
+            CHANNEL_ID,
+            LanguageCode.en,
+        );
+        expect(result.query).toEqual({
+            bool: {
+                filter: [
+                    CHANNEL_ID_TERM,
+                    LANGUAGE_CODE_TERM,
+                    {
+                        bool: {
+                            should: [{ term: { facetValueIds: '1' } }, { term: { facetValueIds: '2' } }],
+                        },
+                    },
+                    { term: { facetValueIds: '1' } },
+                    {
+                        bool: {
+                            should: [{ term: { facetValueIds: '2' } }, { term: { facetValueIds: '3' } }],
+                        },
+                    },
+                ],
+            },
+        });
+    });
+
     it('collectionId', () => {
         const result = buildElasticBody({ collectionId: '1' }, searchConfig, CHANNEL_ID, LanguageCode.en);
         expect(result.query).toEqual({

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

@@ -26,6 +26,7 @@ export function buildElasticBody(
         sort,
         priceRangeWithTax,
         priceRange,
+        facetValueFilters,
     } = input;
     const query: any = {
         bool: {},
@@ -59,6 +60,24 @@ export function buildElasticBody(
             },
         ]);
     }
+    if (facetValueFilters && facetValueFilters.length) {
+        ensureBoolFilterExists(query);
+        facetValueFilters.forEach(facetValueFilter => {
+            if (facetValueFilter.and && facetValueFilter.or && facetValueFilter.or.length) {
+                throw Error('facetId and facetIds cannot be specified simultaneously');
+            }
+
+            if (facetValueFilter.and) {
+                query.bool.filter.push({ term: { facetValueIds: facetValueFilter.and } });
+            }
+
+            if (facetValueFilter.or && facetValueFilter.or.length) {
+                query.bool.filter.push({
+                    bool: { ['should']: facetValueFilter.or.map(id => ({ term: { facetValueIds: id } })) },
+                });
+            }
+        });
+    }
     if (collectionId) {
         ensureBoolFilterExists(query);
         query.bool.filter.push({ term: { collectionIds: collectionId } });

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-admin.json


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-shop.json


Некоторые файлы не были показаны из-за большого количества измененных файлов