Skip to content

Create custom Aggregation

Create aggregation class

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;

use Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\AbstractRangeAggregation;
use Ibexa\Contracts\Core\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:

An aggregation can also implement one of the following interfaces:

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 is 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
services:
    app.search.solr.query.aggregation_visitor.priority_range_aggregation:
    class: Ibexa\Solr\Query\Common\AggregationVisitor\RangeAggregationVisitor
    factory: [ '@Ibexa\Solr\Query\Common\AggregationVisitor\Factory\SearchFieldAggregationVisitorFactory', 'createRangeAggregationVisitor' ]
    arguments:
        $aggregationClass: 'App\Query\Aggregation\Solr\PriorityRangeAggregation'
        $searchIndexFieldName: 'priority_i'
    tags:
        - { name: ibexa.search.solr.query.location.aggregation.visitor }
1
2
3
4
5
6
7
8
9
services:
    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 }

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

In this example, the field is priority_i which exists only for locations.

Tag the service with ibexa.search.solr.query.location.aggregation.visitor.

For content-based aggregations, use the ibexa.search.solr.query.content.aggregation.visitor tag.

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

For content-based aggregations, use the ibexa.search.elasticsearch.query.content.aggregation.visitor tag.

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.search.solr.query.location.aggregation.result.extractor.

As $keyMapper to transform raw keys into more usable objects or scalar values, use IntRangeAggregationKeyMapper or create your own implementing RangeAggregationKeyMapper.

1
2
3
4
5
6
7
8
services:
    app.search.solr.query.aggregation_result_extractor.priority_range_aggregation:
    class: Ibexa\Solr\ResultExtractor\AggregationResultExtractor\RangeAggregationResultExtractor
    arguments:
        $aggregationClass: 'App\Query\Aggregation\Solr\PriorityRangeAggregation'
        $keyMapper: 'Ibexa\Solr\ResultExtractor\AggregationResultExtractor\RangeAggregationKeyMapper\IntRangeAggregationKeyMapper'
    tags:
        - { name: ibexa.search.solr.query.location.aggregation.result.extractor }

For other cases, a TermAggregationKeyMapper interface is also available.

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

1
2
3
4
5
6
7
services:
    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 }

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

  • Range
    • Ibexa\Solr\Query\Common\AggregationVisitor\RangeAggregationVisitor
    • Ibexa\Solr\ResultExtractor\AggregationResultExtractor\RangeAggregationResultExtractor
  • Stats (count, minimum, maximum, average, sum)
    • Ibexa\Solr\Query\Common\AggregationVisitor\StatsAggregationVisitor
    • Ibexa\Solr\ResultExtractor\AggregationResultExtractor\StatsAggregationResultExtractor
  • Term
    • Ibexa\Solr\Query\Common\AggregationVisitor\TermAggregationVisitor
    • Ibexa\Solr\ResultExtractor\AggregationResultExtractor\TermAggregationResultExtractor
  • Range
    • Ibexa\ElasticSearchEngine\Query\AggregationVisitor\RangeAggregationVisitor
    • Ibexa\ElasticSearchEngine\Query\ResultExtractor\AggregationResultExtractor\RangeAggregationResultExtractor
  • Stats (count, minimum, maximum, average, sum)
    • Ibexa\ElasticSearchEngine\Query\AggregationVisitor\StatsAggregationVisitor
    • Ibexa\ElasticSearchEngine\Query\ResultExtractor\AggregationResultExtractor\StatsAggregationResultExtractor
  • Term
    • Ibexa\ElasticSearchEngine\Query\AggregationVisitor\TermAggregationVisitor
    • 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
51
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Solr;

use App\Query\Aggregation\PriorityRangeAggregation;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation;
use Ibexa\Contracts\Solr\Query\AggregationVisitor;

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

    /**
     * @param \Ibexa\Contracts\Core\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
52
53
54
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Elasticsearch;

use App\Query\Aggregation\PriorityRangeAggregation;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation;
use Ibexa\Contracts\Elasticsearch\Query\AggregationVisitor;
use Ibexa\Contracts\Elasticsearch\Query\LanguageFilter;

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

    /**
     * @param \App\Query\Aggregation\PriorityRangeAggregation $aggregation
     *
     * @return array<string, array<string, mixed>>
     */
    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.

Finally, register the aggregation visitor as a service.

Tag the aggregation visitor with ibexa.search.solr.query.location.aggregation.visitor:

1
2
3
4
services:
    App\Query\Aggregation\Solr\PriorityRangeAggregationVisitor:
    tags:
        - { name: ibexa.search.solr.query.location.aggregation.visitor }

For content-based aggregations, use the ibexa.search.solr.query.content.aggregation.visitor tag.

Tag the aggregation visitor with ibexa.elasticsearch.query.location.aggregation_visitor:

1
2
3
4
services:
    App\Query\Aggregation\Elasticsearch\PriorityRangeAggregationVisitor:
    tags:
        - { name: ibexa.search.elasticsearch.query.location.aggregation.visitor }

For content-based aggregations, use the ibexa.search.elasticsearch.query.content.aggregation.visitor tag.

Create result extractor

You must also create a result extractor, which implements Ibexa\Contracts\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
42
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Solr;

use App\Query\Aggregation\PriorityRangeAggregation;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation\Range;
use Ibexa\Contracts\Core\Repository\Values\Content\Search\AggregationResult;
use Ibexa\Contracts\Core\Repository\Values\Content\Search\AggregationResult\RangeAggregationResult;
use Ibexa\Contracts\Core\Repository\Values\Content\Search\AggregationResult\RangeAggregationResultEntry;
use Ibexa\Contracts\Solr\ResultExtractor\AggregationResultExtractor;
use stdClass;

final class PriorityRangeAggregationResultExtractor 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
33
<?php

declare(strict_types=1);

namespace App\Query\Aggregation\Elasticsearch;

use App\Query\Aggregation\PriorityRangeAggregation;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Aggregation;
use Ibexa\Contracts\Core\Repository\Values\Content\Search\AggregationResult;
use Ibexa\Contracts\Elasticsearch\Query\AggregationResultExtractor;
use Ibexa\Contracts\Elasticsearch\Query\LanguageFilter;

final class PriorityRangeAggregationResultExtractor 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 the result extractor as a service.

Tag the result extractor with ibexa.search.solr.query.location.aggregation.result.extractor:

1
2
3
4
services:
    App\Query\Aggregation\Solr\PriorityRangeAggregationResultExtractor:
    tags:
        - { name: ibexa.search.solr.query.location.aggregation.result.extractor }

For content-based aggregations, use the ibexa.search.solr.query.content.aggregation.result.extractor tag.

Tag the result extractor with ibexa.elasticsearch.query.location.aggregation_result_extractor:

1
2
3
4
services:
    App\Query\Aggregation\Elasticsearch\PriorityRangeAggregationResultExtractor:
    tags:
        - { name: ibexa.search.elasticsearch.query.location.aggregation.result.extractor }

For content-based aggregations, use the ibexa.search.elasticsearch.query.content.aggregation.result.extractor tag.