Skip to content

Commit c88d809

Browse files
committed
Merge pull request #85 from johannessteu/feature/aggregations
[TASK] Add aggregation functionality
2 parents 5b85014 + 8cbd5d1 commit c88d809

4 files changed

Lines changed: 330 additions & 2 deletions

File tree

Classes/Flowpack/ElasticSearch/ContentRepositoryAdaptor/Eel/ElasticSearchQueryBuilder.php

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class ElasticSearchQueryBuilder implements QueryBuilderInterface, ProtectedConte
6666
*
6767
* @var array
6868
*/
69-
protected $unsupportedFieldsInCountRequest = array('fields', 'sort', 'from', 'size', 'highlight');
69+
protected $unsupportedFieldsInCountRequest = array('fields', 'sort', 'from', 'size', 'highlight', 'aggs', 'aggregations');
7070

7171
/**
7272
* Amount of total items in response without limit
@@ -85,6 +85,13 @@ class ElasticSearchQueryBuilder implements QueryBuilderInterface, ProtectedConte
8585
*/
8686
protected $elasticSearchHitsIndexedByNodeFromLastRequest;
8787

88+
/**
89+
* This (internal) array stores all aggregation results for the last search request
90+
*
91+
* @var array
92+
*/
93+
protected $elasticSearchAggregationsFromLastRequest;
94+
8895
/**
8996
* The ElasticSearch request, as it is being built up.
9097
* @var array
@@ -366,7 +373,7 @@ public function appendAtPath($path, array $data) {
366373
/**
367374
* Add multiple filters to query.filtered.filter
368375
*
369-
* Example Usage::
376+
* Example Usage:
370377
*
371378
* searchFilter = TYPO3.TypoScript:RawArray {
372379
* author = 'Max'
@@ -396,6 +403,94 @@ public function queryFilterMultiple($data, $clauseType = 'must') {
396403
return $this;
397404
}
398405

406+
/**
407+
* This method adds a field based aggregation configuration. This can be used for simple
408+
* aggregations like terms
409+
*
410+
* Example Usage to create a terms aggregation for a property color:
411+
* nodes = ${Search....fieldBasedAggregation("colors", "color").execute()}
412+
*
413+
* Access all aggregation data with {nodes.aggregations} in your fluid template
414+
*
415+
* @param $name
416+
* @param $field
417+
* @param string $type
418+
* @param null $parentPath
419+
* @return $this
420+
*/
421+
public function fieldBasedAggregation($name, $field, $type = "terms", $parentPath = NULL) {
422+
$aggregationDefinition = array(
423+
$type => array(
424+
'field' => $field
425+
)
426+
);
427+
428+
$this->aggregation($name, $aggregationDefinition, $parentPath);
429+
return $this;
430+
}
431+
432+
/**
433+
* This method is used to create any kind of aggregation.
434+
*
435+
* Example Usage to create a terms aggregation for a property color:
436+
*
437+
* aggregationDefinition = TYPO3.TypoScript:RawArray {
438+
* terms = TYPO3.TypoScript:RawArray {
439+
* field = "color"
440+
* }
441+
* }
442+
*
443+
* nodes = ${Search....aggregation("color", this.aggregationDefinition).execute()}
444+
*
445+
* Access all aggregation data with {nodes.aggregations} in your fluid template
446+
*
447+
* @param string $name
448+
* @param array $aggregationDefinition
449+
* @param null $parentPath
450+
* @return $this
451+
* @throws QueryBuildingException
452+
*/
453+
public function aggregation($name, array $aggregationDefinition, $parentPath = NULL) {
454+
if(!array_key_exists("aggregations", $this->request)) {
455+
$this->request['aggregations'] = array();
456+
}
457+
458+
if($parentPath !== NULL) {
459+
$this->addSubAggregation($parentPath, $name, $aggregationDefinition);
460+
} else {
461+
$this->request['aggregations'][$name] = $aggregationDefinition;
462+
}
463+
464+
return $this;
465+
}
466+
467+
/**
468+
* This is an low level method for internal usage.
469+
* You can add a custom $aggregationConfiguration under a given $parentPath. The $parentPath foo.bar would
470+
* insert your $aggregationConfiguration under
471+
* $this->request['aggregations']['foo']['aggregations']['bar']['aggregations'][$name]
472+
*
473+
* @param $parentPath
474+
* @param $name
475+
* @param array $aggregationConfiguration
476+
* @return $this
477+
* @throws QueryBuildingException
478+
*/
479+
protected function addSubAggregation($parentPath, $name, $aggregationConfiguration) {
480+
// Find the parentPath
481+
$path =& $this->request['aggregations'];
482+
483+
foreach(explode(".", $parentPath) as $subPart) {
484+
if($path == NULL || !array_key_exists($subPart, $path)) {
485+
throw new QueryBuildingException("The parent path ".$subPart." could not be found when adding a sub aggregation");
486+
}
487+
$path =& $path[$subPart]['aggregations'];
488+
}
489+
490+
$path[$name] = $aggregationConfiguration;
491+
return $this;
492+
}
493+
399494
/**
400495
* Get the ElasticSearch request as we need it
401496
*
@@ -519,6 +614,10 @@ public function fetch() {
519614

520615
$this->elasticSearchHitsIndexedByNodeFromLastRequest = $elasticSearchHitPerNode;
521616

617+
if(array_key_exists("aggregations", $treatedContent)) {
618+
$this->elasticSearchAggregationsFromLastRequest = $treatedContent['aggregations'];
619+
}
620+
522621
return array_values($nodes);
523622
}
524623

@@ -534,6 +633,13 @@ public function execute() {
534633
return $result;
535634
}
536635

636+
/**
637+
* @return array
638+
*/
639+
public function getElasticSearchAggregationsFromLastRequest() {
640+
return $this->elasticSearchAggregationsFromLastRequest;
641+
}
642+
537643
/**
538644
* Return the total number of hits for the query.
539645
*

Classes/Flowpack/ElasticSearch/ContentRepositoryAdaptor/Eel/ElasticSearchQueryResult.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ public function getAccessibleCount() {
164164
return count($this->results);
165165
}
166166

167+
/**
168+
* @return array
169+
*/
170+
public function getAggregations() {
171+
$this->initialize();
172+
return $this->elasticSearchQuery->getQueryBuilder()->getElasticSearchAggregationsFromLastRequest();
173+
}
174+
167175
/**
168176
* Returns the ElasticSearch "hit" (e.g. the raw content being transferred back from ElasticSearch)
169177
* for the given node.

README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,121 @@ prototype(Acme.Blog:SingleTag) < prototype(TYPO3.Neos:Template) {
231231
}
232232
```
233233

234+
## Aggregations
235+
236+
Aggregation is an easy way to aggregate your node data in different ways. ElasticSearch provides a couple of different types of
237+
aggregations. Check `https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html` for more
238+
info about aggregations. You can use them to get some simple aggregations like min, max or average values for
239+
your node data. Aggregations also allows you to build a complex filter for e.g. a product search or statistics.
240+
241+
**Aggregation methods**
242+
Right now there are two methods implemented. One generic `aggregation` function that allows you to add any kind of
243+
aggregation definition and a pre-configured `fieldBasedAggregation`. Both methods can be added to your TS search query.
244+
You can nest aggregations by providing a parent name.
245+
246+
* `aggregation($name, array $aggregationDefinition, $parentPath = NULL)` -- generic method to add a $aggregationDefinition under a path $parentPath with the name $name
247+
* `fieldBasedAggregation($name, $field, $type = "terms", $parentPath = NULL)` -- adds a simple filed based Aggregation of type $type with name $name under path $parentPath. Used for simple aggregations like sum, avg, min, max or terms
248+
249+
250+
### Examples
251+
#### Add a average aggregation
252+
To add an average aggregation you can use the fieldBasedAggregation. This snippet would add an average aggregation for
253+
a property price:
254+
```
255+
nodes = ${Search.query(site)...fieldBasedAggregation("avgprice", "price", "avg").execute()}
256+
```
257+
Now you can access your aggregations inside your fluid template with
258+
```
259+
{nodes.aggregations}
260+
```
261+
262+
#### Create a nested aggregation
263+
In this scenario you could have a node that represents a product with the properties price and color. If you would like
264+
to know the average price for all your colors you just nest an aggregation in your TypoScript:
265+
```
266+
nodes = ${Search.query(site)...fieldBasedAggregation("colors", "color").fieldBasedAggregation("avgprice", "price", "avg", "colors").execute()}
267+
```
268+
The first `fieldBasedAggregation` will add a simple terms aggregation (https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html)
269+
with the name colors. So all different colors of your nodetype will be listed here.
270+
The second `fieldBasedAggregation` will add another sub-aggregation named avgprice below your colors-aggregation.
271+
272+
You can nest even more aggregations like this:
273+
```
274+
fieldBasedAggregation("anotherAggregation", "field", "avg", "colors.avgprice")
275+
```
276+
277+
#### Add a custom aggregation
278+
To add a custom aggregation you can use the `aggregation()` method. All you have to do is to provide an array with your
279+
aggregation definition. This example would do the same as the fieldBasedAggregation would do for you:
280+
```
281+
aggregationDefinition = TYPO3.TypoScript:RawArray {
282+
terms = TYPO3.TypoScript:RawArray {
283+
field = "color"
284+
}
285+
}
286+
nodes = ${Search.query(site)...aggregation("color", this.aggregationDefinition).execute()}
287+
```
288+
289+
#### Product filter
290+
This is a more complex scenario. With this snippet we will create a full product filter based on your selected Nodes. Imagine
291+
an NodeTye ProductList with an property `products`. This property contains a comma separated list of sku's. This could also
292+
be a reference on other products.
293+
294+
```
295+
prototype(Vendor.Name:FilteredProductList) < prototype(TYPO3.Neos:Content)
296+
prototype(Vendor.Name:FilteredProductList) {
297+
298+
// Create SearchFilter for products
299+
searchFilter = TYPO3.TypoScript:RawArray {
300+
sku = ${String.split(q(node).property("products"), ",")}
301+
}
302+
303+
# Search for all products that matches your queryFilter and add aggregations
304+
filter = ${Search.query(site).nodeType("Vendor.Name:Product").queryFilterMultiple(this.searchFilter, "must").fieldBasedAggregation("color", "color").fieldBasedAggregation("size", "size").execute()}
305+
306+
# Add more filter if get/post params are set
307+
searchFilter.color = ${request.arguments.color}
308+
searchFilter.color.@if.onlyRenderWhenFilterColorIsSet = ${request.arguments.color != ""}
309+
searchFilter.size = ${request.arguments.size}
310+
searchFilter.size.@if.onlyRenderWhenFilterSizeIsSet = ${request.arguments.size != ""}
311+
312+
# filter your products
313+
products = ${Search.query(site).nodeType("Vendor.Name:Product").queryFilterMultiple(this.searchFilter, "must").execute()}
314+
315+
# don't cache this element
316+
@cache {
317+
mode = 'uncached'
318+
context {
319+
1 = 'node'
320+
2 = 'site'
321+
}
322+
}
323+
```
324+
325+
In the first lines we will add a new searchFilter variable and add your selected sku's as a filter. Based on this selection
326+
we will add two aggregations of type terms. You can access the filter in your template with `{filter.aggregation}`. With
327+
this information it is easy to create a form with some select fields with all available options. If you submit the form
328+
just call the same page and add the get parameter color and/or size.
329+
The next lines will parse those parameters and add them to the searchFilter. Based on your selection all products will
330+
be fetched and passed to your template.
331+
332+
333+
**Important notice**
334+
335+
If you do use the terms filter be aware of ElasticSearchs analyze functionality. You might want to disable this for all
336+
your filterable properties like this:
337+
```
338+
'Vendor.Name:Product'
339+
properties:
340+
color:
341+
type: string
342+
defaultValue: ''
343+
search:
344+
elasticSearchMapping:
345+
type: "string"
346+
include_in_all: false
347+
index: 'not_analyzed'
348+
```
234349

235350
## Fulltext Search / Indexing
236351

0 commit comments

Comments
 (0)