Repository: ruflin/Elastica (php)

Issue:
Missing Bucket Sort Aggregation
The Bucket Sort Aggregation is missing.
Elastic Search documentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-sort-aggregation.html

see #1787

Pull request:
Add Bucket sort aggregation
I tried to stick to existing code organization as much as possible.
The tests are very minimal.

Fixes #2228

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e82f41c6..cbfb4156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 * Added support for the multi-match query type `bool_prefix` [#2220](https://github.com/ruflin/Elastica/pull/2220)
 * Supported PHP 8.4 [#2221](https://github.com/ruflin/Elastica/pull/2221)
 * Added support for custom key to IpRange and GeoDistance `addRange` using a common trait [#2227](https://github.com/ruflin/Elastica/pull/2227)
+* Added bucket sort aggregation [#2229](https://github.com/ruflin/Elastica/pull/2229)
 
 ### Changed
 
diff --git a/src/Aggregation/BucketSort.php b/src/Aggregation/BucketSort.php
new file mode 100644
index 00000000..5d404cfa
--- /dev/null
+++ b/src/Aggregation/BucketSort.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Elastica\Aggregation;
+
+use Elastica\Exception\InvalidException;
+
+class BucketSort extends AbstractAggregation implements GapPolicyInterface
+{
+    use Traits\GapPolicyTrait;
+
+    public function toArray(): array
+    {
+        if (!$this->hasParam('sort') && !$this->hasParam('size') && !$this->hasParam('from')) {
+            throw new InvalidException('Either the sort param, the size param or the from param should be set');
+        }
+
+        return parent::toArray();
+    }
+
+    /**
+     * The number of buckets to return. Defaults to all buckets of the parent aggregation.
+     *
+     * @return $this
+     */
+    public function setSize(int $size): self
+    {
+        return $this->setParam('size', $size);
+    }
+
+    /**
+     * Buckets in positions prior to the set value will be truncated.
+     *
+     * @return $this
+     */
+    public function setFrom(int $from): self
+    {
+        return $this->setParam('from', $from);
+    }
+
+    /**
+     * How the top matching hits should be sorted. By default the hits are sorted by the score of the main query.
+     *
+     * @param string $aggregationName the name of an aggregation
+     * @param string $direction       "asc" or "desc"
+     */
+    public function addSort(string $aggregationName, string $direction): self
+    {
+        return $this->addParam('sort', [$aggregationName => ['order' => $direction]]);
+    }
+}


Test patch:
diff --git a/tests/Aggregation/BucketSortTest.php b/tests/Aggregation/BucketSortTest.php
new file mode 100644
index 00000000..30d899a5
--- /dev/null
+++ b/tests/Aggregation/BucketSortTest.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Elastica\Test\Aggregation;
+
+use Elastica\Aggregation\BucketSort;
+use Elastica\Aggregation\Sum;
+use Elastica\Aggregation\Terms;
+use Elastica\Document;
+use Elastica\Index;
+use Elastica\Mapping;
+use Elastica\Query;
+
+/**
+ * @internal
+ */
+class BucketSortTest extends BaseAggregationTest
+{
+    /**
+     * @group functional
+     */
+    public function testBucketSortAggregation(): void
+    {
+        $bucketSortAggregation = new BucketSort('sort_by_bucket');
+        $bucketSortAggregation->addSort('sum_metric_a', 'desc');
+        $agg = $this->getAggregation();
+        $agg->addAggregation($bucketSortAggregation);
+
+        $query = new Query();
+        $query->addAggregation($agg);
+        $results = $this->_getIndexForTest()->search($query)->getAggregations();
+
+        $this->assertSame('bar', $results['terms']['buckets'][0]['key']);
+
+        $bucketSortAggregation = new BucketSort('sort_by_bucket');
+        $bucketSortAggregation->addSort('sum_metric_a', 'asc');
+        $agg = $this->getAggregation();
+        $agg->addAggregation($bucketSortAggregation);
+
+        $query = new Query();
+        $query->addAggregation($agg);
+        $results = $this->_getIndexForTest()->search($query)->getAggregations();
+
+        $this->assertSame('foo', $results['terms']['buckets'][0]['key']);
+    }
+
+    protected function _getIndexForTest(): Index
+    {
+        $index = $this->_createIndex();
+        $index->setMapping(new Mapping([
+            'field_a' => ['type' => 'keyword'],
+            'metric_a' => ['type' => 'integer'],
+        ]));
+
+        $index->addDocuments([
+            new Document('1', ['field_a' => 'foo', 'metric_a' => 1]),
+            new Document('2', ['field_a' => 'foo', 'metric_a' => 1]),
+            new Document('3', ['field_a' => 'foo', 'metric_a' => 1]),
+            new Document('4', ['field_a' => 'bar', 'metric_a' => 10]),
+            new Document('5', ['field_a' => 'bar', 'metric_a' => 10]),
+            new Document('6', ['field_a' => 'bar', 'metric_a' => 10]),
+        ]);
+
+        $index->refresh();
+
+        return $index;
+    }
+
+    private function getAggregation(): Terms
+    {
+        $agg = new Terms('terms');
+        $agg->setField('field_a');
+
+        $subAgg = new Sum('sum_metric_a');
+        $subAgg->setField('metric_a');
+        $agg->addAggregation($subAgg);
+
+        $subAgg = new Sum('sum_metric_b');
+        $subAgg->setField('metric_b');
+        $agg->addAggregation($subAgg);
+
+        return $agg;
+    }
+}