Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,15 @@ AggregationInterface ─┬─ TermsAggregation (uses FilterableAggregation, Glo
├─ MultiTermsAggregation (uses FilterableAggregation, GlobalizableAggregation, SizeableAggregation)
├─ HistogramAggregation (uses FilterableAggregation, GlobalizableAggregation)
├─ SumAggregation (uses FilterableAggregation, GlobalizableAggregation)
├─ CardinalityAggregation (uses FilterableAggregation, GlobalizableAggregation)
└─ CompositeAggregation [abstract] ─ user-defined composites

SortInterface ─┬─ FieldSort
└─ ScoreSort

Exception hierarchy:
QueryException [abstract] ─ EmptyBoolQueryException, EmptyNestedQueryException, EmptyRangeQueryException, EmptyTermsQueryException, InvalidOperatorQueryException, InvalidRelationQueryException
AggregationException [abstract] ─ DuplicatedContainerAggregationException, DuplicatedNestedAggregationException, InvalidAggregationSizeException, InvalidContainerAggregationException, InvalidIntervalException, NotEnoughFieldsAggregationException
AggregationException [abstract] ─ DuplicatedContainerAggregationException, DuplicatedNestedAggregationException, InvalidAggregationSizeException, InvalidContainerAggregationException, InvalidIntervalException, InvalidPrecisionThresholdException, NotEnoughFieldsAggregationException
BuilderException [abstract] ─ DuplicatedBuilderAggregationException, InvalidFromException, InvalidSizeException
```

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,24 @@ new SumAggregation('global_total', 'price')
->asGlobal();
```

### CardinalityAggregation

https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-cardinality-aggregation

```php
use Bonu\ElasticsearchBuilder\Aggregation\CardinalityAggregation;

// Count unique values
new CardinalityAggregation('unique_brands', 'brand.keyword');

// With precision threshold for better accuracy on high-cardinality fields
new CardinalityAggregation('unique_brands', 'brand.keyword', 1000);

// Filtered cardinality
new CardinalityAggregation('active_unique_brands', 'brand.keyword')
->query(new TermQuery('status', 'active'));
```

## Sorts

### FieldSort
Expand Down
1 change: 1 addition & 0 deletions src/Aggregation/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Aggregation classes implementing `AggregationInterface`. All immutable; mutation
| `MultiTermsAggregation.php` | Bucket by multiple fields | `FilterableAggregation`, `GlobalizableAggregation`, `SizeableAggregation` |
| `HistogramAggregation.php` | Fixed-interval numeric buckets | `FilterableAggregation`, `GlobalizableAggregation` |
| `SumAggregation.php` | Sum of numeric field values | `FilterableAggregation`, `GlobalizableAggregation` |
| `CardinalityAggregation.php` | Distinct value count (cardinality) | `FilterableAggregation`, `GlobalizableAggregation` |

## ADDING A NEW AGGREGATION

Expand Down
62 changes: 62 additions & 0 deletions src/Aggregation/CardinalityAggregation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Bonu\ElasticsearchBuilder\Aggregation;

use Bonu\ElasticsearchBuilder\Exception\Aggregation\InvalidPrecisionThresholdException;

use function array_filter;

/**
* @see https://www.elastic.co/docs/reference/aggregations/search-aggregations-metrics-cardinality-aggregation
*/
class CardinalityAggregation implements AggregationInterface
{
use FilterableAggregation;
use GlobalizableAggregation;

/**
* @param string|\Stringable $name
* @param string|\Stringable $field
* @param null|int $precisionThreshold
*
* @throws \Bonu\ElasticsearchBuilder\Exception\Aggregation\InvalidPrecisionThresholdException
*/
public function __construct(
protected string | \Stringable $name,
protected string | \Stringable $field,
protected ?int $precisionThreshold = null,
) {
if ($precisionThreshold !== null && $precisionThreshold < 1) {
throw new InvalidPrecisionThresholdException('Precision threshold must be a positive integer. ' . $precisionThreshold . ' given.');
}
}

/**
* @inheritDoc
*/
#[\Override]
public function getName(): string
{
return (string) $this->name;
}

/**
* @inheritDoc
*/
#[\Override]
public function toArray(): array
{
$value = ['cardinality' => array_filter([
'field' => (string) $this->field,
'precision_threshold' => $this->precisionThreshold,
], static fn (mixed $value): bool => $value !== null)];
$value = $this->addFilterToAggregation($value, $this->getName());
$value = $this->addGlobalToAggregation($value, $this->getName());

return [
$this->getName() => $value,
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Bonu\ElasticsearchBuilder\Exception\Aggregation;

class InvalidPrecisionThresholdException extends AggregationException
{
}
46 changes: 46 additions & 0 deletions tests/Integration/Aggregation/CardinalityAggregationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Bonu\ElasticsearchBuilder\Tests\Integration\Aggregation;

use PHPUnit\Framework\Attributes\Test;
use Bonu\ElasticsearchBuilder\QueryBuilder;
use Bonu\ElasticsearchBuilder\Tests\IntegrationTestCase;
use Bonu\ElasticsearchBuilder\Aggregation\CardinalityAggregation;

/**
* @internal
*/
final class CardinalityAggregationTest extends IntegrationTestCase
{
#[Test]
public function itCountsDistinctValues(): void
{
$response = $this->client?->search(
new QueryBuilder(self::INDEX)
->aggregation(new CardinalityAggregation('unique_album_types', 'album_type'))
->size(1)
->build()
)->asArray();

$this->assertArrayHasKey('aggregations', $response);
$this->assertArrayHasKey('value', $response['aggregations']['unique_album_types']);
$this->assertGreaterThan(0, $response['aggregations']['unique_album_types']['value']);
}

#[Test]
public function itCountsDistinctValuesWithPrecisionThreshold(): void
{
$response = $this->client?->search(
new QueryBuilder(self::INDEX)
->aggregation(new CardinalityAggregation('unique_albums', 'album_id', 1000))
->size(1)
->build()
)->asArray();

$this->assertArrayHasKey('aggregations', $response);
$this->assertArrayHasKey('value', $response['aggregations']['unique_albums']);
$this->assertGreaterThan(0, $response['aggregations']['unique_albums']['value']);
}
}
150 changes: 150 additions & 0 deletions tests/Unit/Aggregation/CardinalityAggregationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

declare(strict_types=1);

namespace Bonu\ElasticsearchBuilder\Tests\Unit\Aggregation;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\Depends;
use Bonu\ElasticsearchBuilder\Tests\TestCase;
use PHPUnit\Framework\Attributes\DependsExternal;
use Bonu\ElasticsearchBuilder\Tests\Fixture\BoolQueryFixture;
use Bonu\ElasticsearchBuilder\Aggregation\CardinalityAggregation;
use Bonu\ElasticsearchBuilder\Tests\Unit\Aggregation\Trait\FilterableAggregationTest;
use Bonu\ElasticsearchBuilder\Exception\Aggregation\InvalidPrecisionThresholdException;
use Bonu\ElasticsearchBuilder\Tests\Unit\Aggregation\Trait\GlobalizableAggregationTest;

/**
* @internal
*/
final class CardinalityAggregationTest extends TestCase
{
#[Test]
public function itBuildsBasicCardinalityAggregation(): void
{
$agg = new CardinalityAggregation('unique_colors', 'color');

$this->assertSame([
'unique_colors' => [
'cardinality' => [
'field' => 'color',
],
],
], $agg->toArray());
}

#[Depends('itBuildsBasicCardinalityAggregation')]
#[Test]
public function itBuildsWithPrecisionThreshold(): void
{
$agg = new CardinalityAggregation('unique_colors', 'color', 100);

$this->assertSame([
'unique_colors' => [
'cardinality' => [
'field' => 'color',
'precision_threshold' => 100,
],
],
], $agg->toArray());
}

#[Test]
public function itThrowsExceptionForZeroPrecisionThreshold(): void
{
$this->expectException(InvalidPrecisionThresholdException::class);

new CardinalityAggregation('unique_colors', 'color', 0);
}

#[Test]
public function itThrowsExceptionForNegativePrecisionThreshold(): void
{
$this->expectException(InvalidPrecisionThresholdException::class);

new CardinalityAggregation('unique_colors', 'color', -5);
}

#[Depends('itBuildsBasicCardinalityAggregation')]
#[DependsExternal(GlobalizableAggregationTest::class, 'itAddsGlobalToAggregation')]
#[Test]
public function itCanBeGlobal(): void
{
$agg = new CardinalityAggregation('unique_colors', 'color');
$agg = $agg->asGlobal();

$this->assertEquals([
'unique_colors' => [
'global' => (object) [],
'aggs' => [
'unique_colors' => [
'cardinality' => [
'field' => 'color',
],
],
],
],
], $agg->toArray());
}

#[Depends('itBuildsBasicCardinalityAggregation')]
#[DependsExternal(FilterableAggregationTest::class, 'itAddsFilterToAggregation')]
#[Test]
public function itCanBeFiltered(): void
{
$agg = new CardinalityAggregation('unique_colors', 'color');
$agg = $agg->query(new BoolQueryFixture('foo'));

$this->assertSame([
'unique_colors' => [
'filter' => [
'foo' => 'fixture_for_bool_query',
],
'aggs' => [
'unique_colors' => [
'cardinality' => [
'field' => 'color',
],
],
],
],
], $agg->toArray());
}

#[Depends('itBuildsBasicCardinalityAggregation')]
#[Depends('itCanBeFiltered')]
#[Depends('itCanBeGlobal')]
#[Test]
public function itCanBeGlobalAndFilteredTogether(): void
{
$agg = new CardinalityAggregation('unique_colors', 'color');
$agg = $agg->asGlobal()->query(new BoolQueryFixture('foo'));

$this->assertEquals([
'unique_colors' => [
'global' => (object) [],
'aggs' => [
'unique_colors' => [
'filter' => [
'foo' => 'fixture_for_bool_query',
],
'aggs' => [
'unique_colors' => [
'cardinality' => [
'field' => 'color',
],
],
],
],
],
],
], $agg->toArray());
}

#[Test]
public function itReturnsCorrectName(): void
{
$agg = new CardinalityAggregation('unique_colors', 'color');
$this->assertSame('unique_colors', $agg->getName());
}
}