Skip to content

Commit

Permalink
feat(elasticsearch-plugin): Extend response with price range data
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Aug 22, 2019
1 parent 7191842 commit 81eff46
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 29 deletions.
2 changes: 0 additions & 2 deletions packages/elasticsearch-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,3 @@ The `ElasticsearchPlugin` uses Elasticsearch to power the the Vendure product se
`npm install @vendure/elasticsearch-plugin`

For documentation, see [www.vendure.io/docs/plugins/elasticsearch-plugin](https://www.vendure.io/docs/plugins/elasticsearch-plugin)

Status: work in progress
9 changes: 9 additions & 0 deletions packages/elasticsearch-plugin/src/elasticsearch-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Omit } from '@vendure/common/lib/omit';
import { Allow, Ctx, FacetValue, RequestContext, SearchResolver } from '@vendure/core';

import { ElasticsearchService } from './elasticsearch.service';
import { SearchPriceRange } from './types';

@Resolver('SearchResponse')
export class ShopElasticSearchResolver implements Omit<SearchResolver, 'reindex'> {
Expand All @@ -34,6 +35,14 @@ export class ShopElasticSearchResolver implements Omit<SearchResolver, 'reindex'
): Promise<Array<{ facetValue: FacetValue; count: number }>> {
return this.elasticsearchService.facetValues(ctx, parent.input, true);
}

@ResolveProperty()
async priceRange(
@Ctx() ctx: RequestContext,
@Parent() parent: { input: SearchInput },
): Promise<SearchPriceRange> {
return this.elasticsearchService.priceRange(ctx, parent.input);
}
}

@Resolver('SearchResponse')
Expand Down
79 changes: 77 additions & 2 deletions packages/elasticsearch-plugin/src/elasticsearch.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DeepRequired,
FacetValue,
FacetValueService,
InternalServerError,
Logger,
RequestContext,
SearchService,
Expand All @@ -22,7 +23,14 @@ import {
} from './constants';
import { ElasticsearchIndexService } from './elasticsearch-index.service';
import { ElasticsearchOptions } from './options';
import { ProductIndexItem, SearchHit, SearchResponseBody, VariantIndexItem } from './types';
import {
ElasticSearchResponse,
ProductIndexItem,
SearchHit,
SearchPriceRange,
SearchResponseBody,
VariantIndexItem,
} from './types';

@Injectable()
export class ElasticsearchService {
Expand Down Expand Up @@ -66,7 +74,7 @@ export class ElasticsearchService {
ctx: RequestContext,
input: SearchInput,
enabledOnly: boolean = false,
): Promise<Omit<SearchResponse, 'facetValues'>> {
): Promise<Omit<ElasticSearchResponse, 'facetValues' | 'priceRange'>> {
const { indexPrefix } = this.options;
const { groupByProduct } = input;
const elasticSearchBody = buildElasticBody(input, this.options.searchConfig, enabledOnly);
Expand Down Expand Up @@ -130,6 +138,73 @@ export class ElasticsearchService {
});
}

async priceRange(ctx: RequestContext, input: SearchInput): Promise<SearchPriceRange> {
const { indexPrefix, searchConfig } = this.options;
const { groupByProduct } = input;
const elasticSearchBody = buildElasticBody(input, searchConfig, true);
elasticSearchBody.from = 0;
elasticSearchBody.size = 0;
elasticSearchBody.aggs = {
minPrice: {
min: {
field: groupByProduct ? 'priceMin' : 'price',
},
},
minPriceWithTax: {
min: {
field: groupByProduct ? 'priceWithTaxMin' : 'priceWithTax',
},
},
maxPrice: {
max: {
field: groupByProduct ? 'priceMax' : 'price',
},
},
maxPriceWithTax: {
max: {
field: groupByProduct ? 'priceWithTaxMax' : 'priceWithTax',
},
},
prices: {
histogram: {
field: groupByProduct ? 'priceMin' : 'price',
interval: searchConfig.priceRangeBucketInterval,
},
},
pricesWithTax: {
histogram: {
field: groupByProduct ? 'priceWithTaxMin' : 'priceWithTax',
interval: searchConfig.priceRangeBucketInterval,
},
},
};
const { body }: { body: SearchResponseBody<VariantIndexItem> } = await this.client.search({
index: indexPrefix + (input.groupByProduct ? PRODUCT_INDEX_NAME : VARIANT_INDEX_NAME),
type: input.groupByProduct ? PRODUCT_INDEX_TYPE : VARIANT_INDEX_TYPE,
body: elasticSearchBody,
});

const { aggregations } = body;
if (!aggregations) {
throw new InternalServerError(
'An error occurred when querying Elasticsearch for priceRange aggregations',
);
}
const mapPriceBuckets = (b: { key: string; doc_count: number }) => ({
to: Number.parseInt(b.key, 10) + searchConfig.priceRangeBucketInterval,
count: b.doc_count,
});

return {
min: aggregations.minPrice.value,
minWithTax: aggregations.minPriceWithTax.value,
max: aggregations.maxPrice.value,
maxWithTax: aggregations.maxPriceWithTax.value,
buckets: aggregations.prices.buckets.map(mapPriceBuckets).filter(x => 0 < x.count),
bucketsWithTax: aggregations.prices.buckets.map(mapPriceBuckets).filter(x => 0 < x.count),
};
}

/**
* Rebuilds the full search index.
*/
Expand Down
37 changes: 37 additions & 0 deletions packages/elasticsearch-plugin/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,42 @@ export interface SearchConfig {
* Set custom boost values for particular fields when matching against a search term.
*/
boostFields?: BoostFieldsConfig;
/**
* @description
* The interval used to group search results into buckets according to price range. For example, setting this to
* `2000` will group into buckets every $20.00:
*
* ```JSON
* {
* "data": {
* "search": {
* "totalItems": 32,
* "priceRange": {
* "buckets": [
* {
* "to": 2000,
* "count": 21
* },
* {
* "to": 4000,
* "count": 7
* },
* {
* "to": 6000,
* "count": 3
* },
* {
* "to": 12000,
* "count": 1
* }
* ]
* }
* }
* }
* }
* ```
*/
priceRangeBucketInterval: number;
}

/**
Expand Down Expand Up @@ -133,6 +169,7 @@ export const defaultOptions: DeepRequired<ElasticsearchOptions> = {
description: 1,
sku: 1,
},
priceRangeBucketInterval: 1000,
},
};

Expand Down
133 changes: 132 additions & 1 deletion packages/elasticsearch-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Type,
VendurePlugin,
} from '@vendure/core';
import { gql } from 'apollo-server-core';

import { ELASTIC_SEARCH_CLIENT, ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants';
import { ElasticsearchIndexService } from './elasticsearch-index.service';
Expand All @@ -23,6 +24,26 @@ import { ElasticsearchService } from './elasticsearch.service';
import { ElasticsearchIndexerController } from './indexer.controller';
import { ElasticsearchOptions, mergeWithDefaults } from './options';

const schemaExtension = gql`
extend type SearchResponse {
priceRange: SearchResponsePriceRange!
}
type SearchResponsePriceRange {
min: Int!
minWithTax: Int!
max: Int!
maxWithTax: Int!
buckets: [PriceRangeBucket!]!
bucketsWithTax: [PriceRangeBucket!]!
}
type PriceRangeBucket {
to: Int!
count: Int!
}
`;

/**
* @description
* This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
Expand All @@ -36,6 +57,10 @@ import { ElasticsearchOptions, mergeWithDefaults } from './options';
*
* `npm install \@vendure/elasticsearch-plugin`
*
* Make sure to remove the `DefaultSearchPlugin` if it is still in the VendureConfig plugins array.
*
* Then add the `ElasticsearchPlugin`, calling the `.init()` method with {@link ElasticsearchOptions}:
*
* @example
* ```ts
* import { ElasticsearchPlugin } from '\@vendure/elasticsearch-plugin';
Expand All @@ -51,6 +76,112 @@ import { ElasticsearchOptions, mergeWithDefaults } from './options';
* };
* ```
*
* ## Search API Extensions
* This plugin extends the default search API, allowing richer querying of your product data.
*
* The [SearchResponse](/docs/graphql-api/admin/object-types/#searchresponse) type is extended with information
* about price ranges in the result set:
* ```SDL
* extend type SearchResponse {
* priceRange: SearchResponsePriceRange!
* }
*
* type SearchResponsePriceRange {
* min: Int!
* minWithTax: Int!
* max: Int!
* maxWithTax: Int!
* buckets: [PriceRangeBucket!]!
* bucketsWithTax: [PriceRangeBucket!]!
* }
*
* type PriceRangeBucket {
* to: Int!
* count: Int!
* }
* ```
*
* This `SearchResponsePriceRange` type allows you to query data about the range of prices in the result set.
*
* ## Example Request & Response
*
* ```SDL
* {
* search (input: { term: "table easel", groupByProduct: true }){
* totalItems
* priceRange {
* min
* max
* buckets {
* to
* count
* }
* }
* items {
* productName
* score
* price {
* ...on PriceRange {
* min
* max
* }
* }
* }
* }
* }
* ```
*
* ```JSON
*{
* "data": {
* "search": {
* "totalItems": 9,
* "priceRange": {
* "min": 999,
* "max": 6396,
* "buckets": [
* {
* "to": 1000,
* "count": 1
* },
* {
* "to": 2000,
* "count": 2
* },
* {
* "to": 3000,
* "count": 3
* },
* {
* "to": 4000,
* "count": 1
* },
* {
* "to": 5000,
* "count": 1
* },
* {
* "to": 7000,
* "count": 1
* }
* ]
* },
* "items": [
* {
* "productName": "Loxley Yorkshire Table Easel",
* "score": 30.58831,
* "price": {
* "min": 4984,
* "max": 4984
* }
* },
* // ... truncated
* ]
* }
* }
*}
* ```
*
* @docsCategory ElasticsearchPlugin
*/
@VendurePlugin({
Expand All @@ -62,7 +193,7 @@ import { ElasticsearchOptions, mergeWithDefaults } from './options';
{ provide: ELASTIC_SEARCH_CLIENT, useFactory: () => ElasticsearchPlugin.client },
],
adminApiExtensions: { resolvers: [AdminElasticSearchResolver] },
shopApiExtensions: { resolvers: [ShopElasticSearchResolver] },
shopApiExtensions: { resolvers: [ShopElasticSearchResolver], schema: schemaExtension },
workers: [ElasticsearchIndexerController],
})
export class ElasticsearchPlugin implements OnVendureBootstrap, OnVendureClose {
Expand Down
Loading

0 comments on commit 81eff46

Please sign in to comment.