Skip to content

Create custom Aggregation

To create a custom Aggregation, create an aggregation class. In the following example, an aggregation groups the Location query results by the Location priority:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Solr;

use eZ\Publish\API\Repository\Values\Content\Query\Aggregation\AbstractRangeAggregation;
use eZ\Publish\API\Repository\Values\Content\Query\Aggregation\LocationAggregation;

final class PriorityRangeAggregation extends AbstractRangeAggregation implements LocationAggregation
{
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Elasticsearch;

use eZ\Publish\API\Repository\Values\Content\Query\Aggregation\AbstractRangeAggregation;
use eZ\Publish\API\Repository\Values\Content\Query\Aggregation\LocationAggregation;

final class PriorityRangeAggregation extends AbstractRangeAggregation implements LocationAggregation
{
}

The PriorityRangeAggregation class extends AbstractRangeAggregation. The name of the class indicates that it aggregates the results by using the Range aggregation.

An aggregation must implement the Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation interface or inherit one of following abstract classes:

  • Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\AbstractRangeAggregation
  • Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\AbstractStatsAggregation
  • Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\AbstractTermAggregation

An aggregation can also implement one of the following interfaces:

  • Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\FieldAggregation, based on content Field
  • Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\LocationAggregation, based on content Location
  • Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\RawAggregation, based on details of the index structure

Aggregation definition

An aggregation definition must contain at least the name of an aggregation and optional aggregation parameters, such as, for example, the path (string) that is used to limit aggregation results to a specific subtree, Content Type identifier, or Field definition identifier, which will be mapped to the search index field name.

Aggregation definition must be independent of the search engine used.

A custom aggregation requires that the following elements are provided:

  • An aggregation visitor that returns an array of results
  • A result extractor that transforms raw aggregation results from the search engine into AggregationResult objects

In simpler cases, you can apply one of the built-in visitors that correspond to the aggregation type. The example below uses RangeAggregationVisitor:

1
2
3
4
5
6
7
8
9
App\Query\Aggregation\Solr\PriorityAggregationVisitor:
    class: EzSystems\EzPlatformSolrSearchEngine\Query\Common\AggregationVisitor\RangeAggregationVisitor
    factory: ['@EzSystems\EzPlatformSolrSearchEngine\Query\Common\AggregationVisitor\Factory\ContentFieldAggregationVisitorFactory', 'createRangeAggregationVisitor']
    arguments:
        $aggregationClass: 'App\Query\Aggregation\PriorityRangeAggregation'
        $searchIndexFieldName: 'priority_i'
    tags:
        - { name: ezplatform.search.solr.query.content.aggregation_visitor }
        - { name: ezplatform.search.solr.query.location.aggregation_visitor }
1
2
3
4
5
6
7
8
9
app.search.elasticsearch.query.aggregation_visitor.priority_range_aggregation:
    class: Ibexa\Elasticsearch\Query\AggregationVisitor\RangeAggregationVisitor
    factory: [ '@Ibexa\Elasticsearch\Query\AggregationVisitor\Factory\SearchFieldAggregationVisitorFactory', 'createRangeAggregationVisitor' ]
    arguments:
        $aggregationClass: 'App\Query\Aggregation\Elasticsearch\PriorityRangeAggregation'
        $searchIndexFieldName: 'priority_i'
    tags:
        - { name: ibexa.search.elasticsearch.query.location.aggregation.visitor }
        - { name: ibexa.search.elasticsearch.query.content.aggregation.visitor }

The visitor is created by SearchFieldAggregationVisitorFactory. You provide it with two arguments:

  • The aggregation class in aggregationClass
  • The field name in search index in searchIndexFieldName

Tag the service with ibexa.solr.query.location.aggregation_visitor.

Tag the service with ibexa.elasticsearch.query.location.aggregation_visitor.

For the result extractor, you can use the built-in RangeAggregationResultExtractor and provide it with the aggregation class in the aggregationClass parameter.

Tag the service with ibexa.solr.query.location.aggregation_result_extractor.

1
2
3
4
5
6
App\Query\Aggregation\Solr\PriorityAggregationResultExtractor:
    class: EzSystems\EzPlatformSolrSearchEngine\ResultExtractor\AggregationResultExtractor\RangeAggregationResultExtractor
    arguments:
        $aggregationClass: 'App\Query\Aggregation\PriorityRangeAggregation'
    tags:
        - { name: ezplatform.search.solr.query.location.aggregation_result_extractor }

Tag the service with ibexa.search.elasticsearch.query.location.aggregation.result.extractor.

1
2
3
4
5
6
7
app.search.elasticsearch.query.aggregation_result_extractor.priority_range_aggregation:
    class: Ibexa\Elasticsearch\Query\ResultExtractor\AggregationResultExtractor\RangeAggregationResultExtractor
    arguments:
        $aggregationClass: 'App\Query\Aggregation\Elasticsearch\PriorityRangeAggregation'
    tags:
        - { name: ibexa.search.elasticsearch.query.location.aggregation.result.extractor }
        - { name: ibexa.search.elasticsearch.query.content.aggregation.result.extractor }

You can use a different type of aggregation, followed by respective visitor and extractor classes:

  • Ibexa\Solr\Query\Common\AggregationVisitor\StatsAggregationVisitor
  • Ibexa\Solr\Query\Common\AggregationVisitor\TermAggregationVisitor
  • Ibexa\Solr\ResultExtractor\AggregationResultExtractor\StatsAggregationResultExtractor
  • Ibexa\Solr\ResultExtractor\AggregationResultExtractor\TermAggregationResultExtractor
  • Ibexa\ElasticSearchEngine\Query\AggregationVisitor\RangeAggregationVisitor
  • Ibexa\ElasticSearchEngine\Query\AggregationVisitor\StatsAggregationVisitor
  • Ibexa\ElasticSearchEngine\Query\AggregationVisitor\TermAggregationVisitor

  • Ibexa\ElasticSearchEngine\Query\ResultExtractor\AggregationResultExtractor\RangeAggregationResultExtractor

  • Ibexa\ElasticSearchEngine\Query\ResultExtractor\AggregationResultExtractor\StatsAggregationResultExtractor
  • Ibexa\ElasticSearchEngine\Query\ResultExtractor\AggregationResultExtractor\TermAggregationResultExtractor

In a more complex use case, you must create your own visitor and extractor.

Create aggregation visitor

The aggregation visitor must implement Ibexa\Contracts\Solr\Query\AggregationVisitor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Solr;

use eZ\Publish\API\Repository\Values\Content\Query\Aggregation;
use EzSystems\EzPlatformSolrSearchEngine\Query\AggregationVisitor;

final class PriorityRangeAggregationVisitor implements AggregationVisitor
{
    public function canVisit(Aggregation $aggregation, array $languageFilter): bool
    {
        return $aggregation instanceof PriorityRangeAggregation;
    }

    /**
     * @param \eZ\Publish\API\Repository\Values\Content\Query\Aggregation\AbstractRangeAggregation $aggregation
     */
    public function visit(
        AggregationVisitor $dispatcherVisitor,
        Aggregation $aggregation,
        array $languageFilter
    ): array {
        $rangeFacets = [];
        foreach ($aggregation->getRanges() as $range) {
            $from = $this->formatRangeValue($range->getFrom());
            $to = $this->formatRangeValue($range->getTo());
            $rangeFacets["${from}_${to}"] = [
                'type' => 'query',
                'q' => sprintf('priority_i:[%s TO %s}', $from, $to),
            ];
        }

        return [
            'type' => 'query',
            'q' => '*:*',
            'facet' => $rangeFacets,
        ];
    }

    private function formatRangeValue($value): string
    {
        if ($value === null) {
            return '*';
        }

        return (string)$value;
    }
}

The aggregation visitor must implement Ibexa\Contracts\ElasticSearchEngine\Query\AggregationVisitor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Elasticsearch;

use eZ\Publish\API\Repository\Values\Content\Query\Aggregation;
use Ibexa\Platform\Contracts\ElasticSearchEngine\Query\AggregationVisitor;
use Ibexa\Platform\Contracts\ElasticSearchEngine\Query\LanguageFilter;

final class PriorityAggregationVisitor implements AggregationVisitor
{
    public function supports(Aggregation $aggregation, LanguageFilter $languageFilter): bool
    {
        return $aggregation instanceof PriorityRangeAggregation;
    }

    /**
     * @param PriorityRangeAggregation $aggregation
     */
    public function visit(AggregationVisitor $dispatcher, Aggregation $aggregation, LanguageFilter $languageFilter): array
    {
        $ranges = [];

        foreach ($aggregation->getRanges() as $range) {
            if ($range->getFrom() !== null && $range->getTo() !== null) {
                $ranges[] = [
                    'from' => $range->getFrom(),
                    'to' => $range->getTo(),
                ];
            } elseif ($range->getFrom() === null && $range->getTo() !== null) {
                $ranges[] = [
                    'to' => $range->getTo(),
                ];
            } elseif ($range->getFrom() !== null && $range->getTo() === null) {
                $ranges[] = [
                    'from' => $range->getFrom(),
                ];
            } else {
                // invalid range
            }
        }

        return [
            'range' => [
                'field' => 'priority_i',
                'ranges' => $ranges,
            ],
        ];
    }
}

The canVisit() method checks whether the provided aggregation is of the supported type (in this case, your custom PriorityRangeAggregation).

The visit() method returns an array of results.

Create result extractor

You must also create a result extractor, which implements Ibexa\Solr\ResultExtractor\AggregationResultExtractor that transforms raw aggregation results from Solr into AggregationResult objects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Solr;

use eZ\Publish\API\Repository\Values\Content\Query\Aggregation;
use eZ\Publish\API\Repository\Values\Content\Query\Aggregation\Range;
use eZ\Publish\API\Repository\Values\Content\Search\AggregationResult;
use eZ\Publish\API\Repository\Values\Content\Search\AggregationResult\RangeAggregationResult;
use eZ\Publish\API\Repository\Values\Content\Search\AggregationResult\RangeAggregationResultEntry;
use EzSystems\EzPlatformSolrSearchEngine\ResultExtractor\AggregationResultExtractor;
use stdClass;

final class PriorityAggregationResultExtractor implements AggregationResultExtractor
{
    public function canVisit(Aggregation $aggregation, array $languageFilter): bool
    {
        return $aggregation instanceof PriorityRangeAggregation;
    }

    public function extract(Aggregation $aggregation, array $languageFilter, stdClass $data): AggregationResult
    {
        $entries = [];
        foreach ($data as $key => $bucket) {
            if ($key === 'count' || strpos($key, '_') === false) {
                continue;
            }
            [$from, $to] = explode('_', $key, 2);
            $entries[] = new RangeAggregationResultEntry(
                new Range(
                    $from !== '*' ? $from : null,
                    $to !== '*' ? $to : null
                ),
                $bucket->count
            );
        }

        return new RangeAggregationResult($aggregation->getName(), $entries);
    }
}

The canVisit() method checks whether the provided aggregation is of the supported type (in this case, your custom PriorityRangeAggregation).

The extract() method converts the raw data provided by the search engine to a RangeAggregationResult object.

You must also create a result extractor, which implements Ibexa\Contracts\ElasticSearchEngine\Query\AggregationResultExtractor that transforms raw aggregation results from Elasticsearch into AggregationResult objects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Elasticsearch;

use eZ\Publish\API\Repository\Values\Content\Query\Aggregation;
use eZ\Publish\API\Repository\Values\Content\Search\AggregationResult;
use Ibexa\Platform\Contracts\ElasticSearchEngine\Query\AggregationResultExtractor;
use Ibexa\Platform\Contracts\ElasticSearchEngine\Query\LanguageFilter;

final class PriorityAggregationResultExtractor implements AggregationResultExtractor
{
    public function supports(Aggregation $aggregation, LanguageFilter $languageFilter): bool
    {
        return $aggregation instanceof PriorityRangeAggregation;
    }

    public function extract(Aggregation $aggregation, LanguageFilter $languageFilter, array $data): AggregationResult
    {
        $entries = [];

        foreach ($data['buckets'] as $bucket) {
            $entries[] = new AggregationResult\RangeAggregationResultEntry(
                new Aggregation\Range($bucket['from'] ?? null, $bucket['to'] ?? null),
                $bucket['doc_count']
            );
        }

        return new AggregationResult\RangeAggregationResult($aggregation->getName(), $entries);
    }
}

The supports() method checks whether the provided aggregation is of the supported type (in this case, your custom PriorityRangeAggregation).

The extract() method converts the raw data provided by the search engine to a RangeAggregationResult object.

Finally, register both the aggregation visitor and the result extractor as services.

Tag the aggregation visitor with ibexa.solr.query.location.aggregation_visitor and the result extractor with ibexa.solr.query.location.aggregation_result_extractor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
    App\Query\Aggregation\Solr\PriorityRangeAggregationVisitor:
        tags:
            - { name: ezplatform.search.solr.query.location.aggregation_visitor }
            - { name: ezplatform.search.solr.query.content.aggregation_visitor }

    App\Query\Aggregation\Solr\PriorityAggregationResultExtractor:
        tags:
            - { name: ezplatform.search.solr.query.location.aggregation_result_extractor }
            - { name: ezplatform.search.solr.query.content.aggregation_result_extractor }

For content-based aggregations, use the ibexa.solr.query.content.aggregation_visitor and ibexa.solr.query.content.aggregation_result_extractor tags respectively.

Tag the aggregation visitor with ibexa.elasticsearch.query.location.aggregation_visitor and the result extractor with ibexa.elasticsearch.query.location.aggregation_result_extractor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
    App\Query\Aggregation\Elasticsearch\PriorityAggregationVisitor:
        tags:
            - { name: ezplatform.search.elasticsearch.query.location.aggregation_visitor }
            - { name: ezplatform.search.elasticsearch.query.content.aggregation_visitor }

    App\Query\Aggregation\Elasticsearch\PriorityAggregationResultExtractor:
        tags:
            - { name: ezplatform.search.elasticsearch.query.location.aggregation_result_extractor }
            - { name: ezplatform.search.elasticsearch.query.content.aggregation_result_extractor }

For content-based aggregations, use the ibexa.elasticsearch.query.content.aggregation_visitor and ibexa.elasticsearch.query.content.aggregation_result_extractor tags respectively.