From 0d04d175815b6dea2a77352e1f4e5dfd0e19e57e Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 13:44:30 +0200 Subject: [PATCH 01/97] feat(aggregation): add value count aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/ValueCount.php | 38 +++++++ .../ElasticQuery/Aggregation/ValueCount.phpt | 99 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/Aggregation/ValueCount.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 9b89655..615ae41 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -78,6 +78,16 @@ Returns the average value of a numeric field. new \Spameri\ElasticQuery\Aggregation\Avg(field: 'price'); ``` +##### ValueCount Aggregation +Counts the number of values extracted from a field. +- Class: `\Spameri\ElasticQuery\Aggregation\ValueCount` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-valuecount-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/ValueCount.php) + +```php +new \Spameri\ElasticQuery\Aggregation\ValueCount(field: 'price'); +``` + ##### TopHits Aggregation Returns the top matching documents per bucket. - Class: `\Spameri\ElasticQuery\Aggregation\TopHits` diff --git a/src/Aggregation/ValueCount.php b/src/Aggregation/ValueCount.php new file mode 100644 index 0000000..7d8ec09 --- /dev/null +++ b/src/Aggregation/ValueCount.php @@ -0,0 +1,38 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'value_count' => [ + 'field' => $this->field, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt new file mode 100644 index 0000000..4667f37 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt @@ -0,0 +1,99 @@ +toArray(); + + \Tester\Assert::true(isset($array['value_count']['field'])); + \Tester\Assert::same('price', $array['value_count']['field']); + } + + + public function testKey(): void + { + $valueCount = new \Spameri\ElasticQuery\Aggregation\ValueCount('price'); + + \Tester\Assert::same('value_count_price', $valueCount->key()); + } + + + public function testCreate(): void + { + $valueCount = new \Spameri\ElasticQuery\Aggregation\ValueCount('price'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_value_count', + null, + $valueCount, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new ValueCount())->run(); From 3a817614c84cacf46abe02e6ec54a8b4a98e392c Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 13:55:10 +0200 Subject: [PATCH 02/97] feat(aggregation): add cardinality aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 23 ++++ src/Aggregation/Cardinality.php | 45 +++++++ .../ElasticQuery/Aggregation/Cardinality.phpt | 110 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/Aggregation/Cardinality.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 615ae41..b6f366e 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -78,6 +78,16 @@ Returns the average value of a numeric field. new \Spameri\ElasticQuery\Aggregation\Avg(field: 'price'); ``` +##### Sum Aggregation +Returns the sum of values of a numeric field. +- Class: `\Spameri\ElasticQuery\Aggregation\Sum` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-sum-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Sum.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Sum(field: 'price'); +``` + ##### ValueCount Aggregation Counts the number of values extracted from a field. - Class: `\Spameri\ElasticQuery\Aggregation\ValueCount` @@ -88,6 +98,19 @@ Counts the number of values extracted from a field. new \Spameri\ElasticQuery\Aggregation\ValueCount(field: 'price'); ``` +##### Cardinality Aggregation +Approximate count of distinct values using HyperLogLog++. +- Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-cardinality-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Cardinality.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Cardinality( + field: 'user_id', + precisionThreshold: 3000, // Optional, default 3000, max 40000 +); +``` + ##### TopHits Aggregation Returns the top matching documents per bucket. - Class: `\Spameri\ElasticQuery\Aggregation\TopHits` diff --git a/src/Aggregation/Cardinality.php b/src/Aggregation/Cardinality.php new file mode 100644 index 0000000..71e63c5 --- /dev/null +++ b/src/Aggregation/Cardinality.php @@ -0,0 +1,45 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->precisionThreshold !== null) { + $array['precision_threshold'] = $this->precisionThreshold; + } + + return [ + 'cardinality' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt new file mode 100644 index 0000000..d92fb2b --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt @@ -0,0 +1,110 @@ +toArray(); + + \Tester\Assert::true(isset($array['cardinality']['field'])); + \Tester\Assert::same('user_id', $array['cardinality']['field']); + \Tester\Assert::false(isset($array['cardinality']['precision_threshold'])); + } + + + public function testToArrayWithPrecisionThreshold(): void + { + $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id', 3000); + + $array = $cardinality->toArray(); + + \Tester\Assert::same(3000, $array['cardinality']['precision_threshold']); + } + + + public function testKey(): void + { + $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id'); + + \Tester\Assert::same('cardinality_user_id', $cardinality->key()); + } + + + public function testCreate(): void + { + $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'distinct_users', + null, + $cardinality, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Cardinality())->run(); From e0047ca09baf7e87410f29e7cec94d92f7018e5d Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 13:55:44 +0200 Subject: [PATCH 03/97] feat(aggregation): add stats aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/Stats.php | 38 +++++++ .../ElasticQuery/Aggregation/Stats.phpt | 99 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/Aggregation/Stats.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index b6f366e..3df1262 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -98,6 +98,16 @@ Counts the number of values extracted from a field. new \Spameri\ElasticQuery\Aggregation\ValueCount(field: 'price'); ``` +##### Stats Aggregation +Returns count, min, max, avg and sum in one call. +- Class: `\Spameri\ElasticQuery\Aggregation\Stats` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-stats-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Stats.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Stats(field: 'price'); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/Stats.php b/src/Aggregation/Stats.php new file mode 100644 index 0000000..76c6a81 --- /dev/null +++ b/src/Aggregation/Stats.php @@ -0,0 +1,38 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'stats' => [ + 'field' => $this->field, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt new file mode 100644 index 0000000..346f1e5 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt @@ -0,0 +1,99 @@ +toArray(); + + \Tester\Assert::true(isset($array['stats']['field'])); + \Tester\Assert::same('price', $array['stats']['field']); + } + + + public function testKey(): void + { + $stats = new \Spameri\ElasticQuery\Aggregation\Stats('price'); + + \Tester\Assert::same('stats_price', $stats->key()); + } + + + public function testCreate(): void + { + $stats = new \Spameri\ElasticQuery\Aggregation\Stats('price'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_stats', + null, + $stats, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Stats())->run(); From f57ca56002f684e24993e1f24f82380e9b5e0cb7 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 13:56:21 +0200 Subject: [PATCH 04/97] feat(aggregation): add extended stats aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/ExtendedStats.php | 45 +++++++ .../Aggregation/ExtendedStats.phpt | 110 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/Aggregation/ExtendedStats.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStats.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 3df1262..a600d29 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -108,6 +108,19 @@ Returns count, min, max, avg and sum in one call. new \Spameri\ElasticQuery\Aggregation\Stats(field: 'price'); ``` +##### ExtendedStats Aggregation +Stats plus variance, standard deviation and bounds. +- Class: `\Spameri\ElasticQuery\Aggregation\ExtendedStats` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-extendedstats-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/ExtendedStats.php) + +```php +new \Spameri\ElasticQuery\Aggregation\ExtendedStats( + field: 'price', + sigma: 3.0, // Optional, default 2.0 +); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/ExtendedStats.php b/src/Aggregation/ExtendedStats.php new file mode 100644 index 0000000..9a85e83 --- /dev/null +++ b/src/Aggregation/ExtendedStats.php @@ -0,0 +1,45 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->sigma !== null) { + $array['sigma'] = $this->sigma; + } + + return [ + 'extended_stats' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStats.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStats.phpt new file mode 100644 index 0000000..9df053c --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStats.phpt @@ -0,0 +1,110 @@ +toArray(); + + \Tester\Assert::true(isset($array['extended_stats']['field'])); + \Tester\Assert::same('price', $array['extended_stats']['field']); + \Tester\Assert::false(isset($array['extended_stats']['sigma'])); + } + + + public function testToArrayWithSigma(): void + { + $extendedStats = new \Spameri\ElasticQuery\Aggregation\ExtendedStats('price', 3.0); + + $array = $extendedStats->toArray(); + + \Tester\Assert::same(3.0, $array['extended_stats']['sigma']); + } + + + public function testKey(): void + { + $extendedStats = new \Spameri\ElasticQuery\Aggregation\ExtendedStats('price'); + + \Tester\Assert::same('extended_stats_price', $extendedStats->key()); + } + + + public function testCreate(): void + { + $extendedStats = new \Spameri\ElasticQuery\Aggregation\ExtendedStats('price'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_extended_stats', + null, + $extendedStats, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new ExtendedStats())->run(); From 94359002e4232f070b905467cef7ebb5733011bb Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 13:56:58 +0200 Subject: [PATCH 05/97] feat(aggregation): add percentiles aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++ src/Aggregation/Percentiles.php | 53 ++++++++ .../ElasticQuery/Aggregation/Percentiles.phpt | 126 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 src/Aggregation/Percentiles.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Percentiles.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index a600d29..40d7f80 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -121,6 +121,19 @@ new \Spameri\ElasticQuery\Aggregation\ExtendedStats( ); ``` +##### Percentiles Aggregation +Calculates percentile values (e.g. p50, p95, p99) over a numeric field. +- Class: `\Spameri\ElasticQuery\Aggregation\Percentiles` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-percentile-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Percentiles.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Percentiles( + field: 'load_time', + percents: [50, 95, 99], // Optional, default [1, 5, 25, 50, 75, 95, 99] +); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/Percentiles.php b/src/Aggregation/Percentiles.php new file mode 100644 index 0000000..88ba211 --- /dev/null +++ b/src/Aggregation/Percentiles.php @@ -0,0 +1,53 @@ + $percents + */ + public function __construct( + private string $field, + private array $percents = [], + private bool $keyed = true, + ) + { + } + + + public function key(): string + { + return 'percentiles_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->percents !== []) { + $array['percents'] = $this->percents; + } + + if ($this->keyed === false) { + $array['keyed'] = false; + } + + return [ + 'percentiles' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Percentiles.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Percentiles.phpt new file mode 100644 index 0000000..de6b857 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Percentiles.phpt @@ -0,0 +1,126 @@ +toArray(); + + \Tester\Assert::true(isset($array['percentiles']['field'])); + \Tester\Assert::same('load_time', $array['percentiles']['field']); + } + + + public function testToArrayWithPercents(): void + { + $percentiles = new \Spameri\ElasticQuery\Aggregation\Percentiles( + 'load_time', + [50, 95, 99], + ); + + $array = $percentiles->toArray(); + + \Tester\Assert::same([50, 95, 99], $array['percentiles']['percents']); + } + + + public function testToArrayUnkeyed(): void + { + $percentiles = new \Spameri\ElasticQuery\Aggregation\Percentiles( + 'load_time', + [], + false, + ); + + $array = $percentiles->toArray(); + + \Tester\Assert::false($array['percentiles']['keyed']); + } + + + public function testKey(): void + { + $percentiles = new \Spameri\ElasticQuery\Aggregation\Percentiles('load_time'); + + \Tester\Assert::same('percentiles_load_time', $percentiles->key()); + } + + + public function testCreate(): void + { + $percentiles = new \Spameri\ElasticQuery\Aggregation\Percentiles('load_time'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'load_time_percentiles', + null, + $percentiles, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Percentiles())->run(); From 58b5be0a63e9271837724079c6b3e774cdcf7270 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 13:57:35 +0200 Subject: [PATCH 06/97] feat(aggregation): add percentile ranks aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/PercentileRanks.php | 50 ++++++++ .../Aggregation/PercentileRanks.phpt | 108 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/Aggregation/PercentileRanks.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/PercentileRanks.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 40d7f80..0e4a80b 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -134,6 +134,19 @@ new \Spameri\ElasticQuery\Aggregation\Percentiles( ); ``` +##### PercentileRanks Aggregation +Calculates the percentile rank for given values. +- Class: `\Spameri\ElasticQuery\Aggregation\PercentileRanks` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-percentile-rank-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/PercentileRanks.php) + +```php +new \Spameri\ElasticQuery\Aggregation\PercentileRanks( + field: 'load_time', + values: [500, 600], +); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/PercentileRanks.php b/src/Aggregation/PercentileRanks.php new file mode 100644 index 0000000..9a2a634 --- /dev/null +++ b/src/Aggregation/PercentileRanks.php @@ -0,0 +1,50 @@ + $values + */ + public function __construct( + private string $field, + private array $values, + private bool $keyed = true, + ) + { + } + + + public function key(): string + { + return 'percentile_ranks_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + 'values' => $this->values, + ]; + + if ($this->keyed === false) { + $array['keyed'] = false; + } + + return [ + 'percentile_ranks' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/PercentileRanks.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/PercentileRanks.phpt new file mode 100644 index 0000000..86c5499 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/PercentileRanks.phpt @@ -0,0 +1,108 @@ +toArray(); + + \Tester\Assert::same('load_time', $array['percentile_ranks']['field']); + \Tester\Assert::same([500, 600], $array['percentile_ranks']['values']); + } + + + public function testKey(): void + { + $percentileRanks = new \Spameri\ElasticQuery\Aggregation\PercentileRanks( + 'load_time', + [500], + ); + + \Tester\Assert::same('percentile_ranks_load_time', $percentileRanks->key()); + } + + + public function testCreate(): void + { + $percentileRanks = new \Spameri\ElasticQuery\Aggregation\PercentileRanks( + 'load_time', + [500, 600], + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'load_time_ranks', + null, + $percentileRanks, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new PercentileRanks())->run(); From 6c29b3e77e19e7f4b2129fe417cc5c8faf2828ff Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 13:58:08 +0200 Subject: [PATCH 07/97] feat(aggregation): add weighted avg aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/WeightedAvg.php | 44 +++++++++ .../ElasticQuery/Aggregation/WeightedAvg.phpt | 99 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 src/Aggregation/WeightedAvg.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 0e4a80b..b8771a3 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -147,6 +147,19 @@ new \Spameri\ElasticQuery\Aggregation\PercentileRanks( ); ``` +##### WeightedAvg Aggregation +Computes a weighted average over two fields (value and weight). +- Class: `\Spameri\ElasticQuery\Aggregation\WeightedAvg` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-weight-avg-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/WeightedAvg.php) + +```php +new \Spameri\ElasticQuery\Aggregation\WeightedAvg( + valueField: 'grade', + weightField: 'weight', +); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/WeightedAvg.php b/src/Aggregation/WeightedAvg.php new file mode 100644 index 0000000..cc0ae48 --- /dev/null +++ b/src/Aggregation/WeightedAvg.php @@ -0,0 +1,44 @@ +valueField; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + return [ + 'weighted_avg' => [ + 'value' => [ + 'field' => $this->valueField, + ], + 'weight' => [ + 'field' => $this->weightField, + ], + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt new file mode 100644 index 0000000..e504268 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt @@ -0,0 +1,99 @@ +toArray(); + + \Tester\Assert::same('grade', $array['weighted_avg']['value']['field']); + \Tester\Assert::same('weight', $array['weighted_avg']['weight']['field']); + } + + + public function testKey(): void + { + $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg('grade', 'weight'); + + \Tester\Assert::same('weighted_avg_grade', $weightedAvg->key()); + } + + + public function testCreate(): void + { + $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg('grade', 'weight'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'weighted_grade', + null, + $weightedAvg, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new WeightedAvg())->run(); From f19ced6d27bb774545f0fe32d0e18e52dea972d5 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:00:02 +0200 Subject: [PATCH 08/97] feat(aggregation): add median absolute deviation aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/MedianAbsoluteDeviation.php | 45 ++++++++ .../Aggregation/MedianAbsoluteDeviation.phpt | 109 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/Aggregation/MedianAbsoluteDeviation.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/MedianAbsoluteDeviation.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index b8771a3..89cce5f 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -160,6 +160,16 @@ new \Spameri\ElasticQuery\Aggregation\WeightedAvg( ); ``` +##### MedianAbsoluteDeviation Aggregation +Computes a robust measure of variability via the median of absolute deviations from the median. +- Class: `\Spameri\ElasticQuery\Aggregation\MedianAbsoluteDeviation` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-median-absolute-deviation-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/MedianAbsoluteDeviation.php) + +```php +new \Spameri\ElasticQuery\Aggregation\MedianAbsoluteDeviation(field: 'rating'); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/MedianAbsoluteDeviation.php b/src/Aggregation/MedianAbsoluteDeviation.php new file mode 100644 index 0000000..c05470f --- /dev/null +++ b/src/Aggregation/MedianAbsoluteDeviation.php @@ -0,0 +1,45 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->compression !== null) { + $array['compression'] = $this->compression; + } + + return [ + 'median_absolute_deviation' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/MedianAbsoluteDeviation.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/MedianAbsoluteDeviation.phpt new file mode 100644 index 0000000..05a5575 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/MedianAbsoluteDeviation.phpt @@ -0,0 +1,109 @@ +toArray(); + + \Tester\Assert::same('rating', $array['median_absolute_deviation']['field']); + \Tester\Assert::false(isset($array['median_absolute_deviation']['compression'])); + } + + + public function testToArrayWithCompression(): void + { + $mad = new \Spameri\ElasticQuery\Aggregation\MedianAbsoluteDeviation('rating', 200); + + $array = $mad->toArray(); + + \Tester\Assert::same(200, $array['median_absolute_deviation']['compression']); + } + + + public function testKey(): void + { + $mad = new \Spameri\ElasticQuery\Aggregation\MedianAbsoluteDeviation('rating'); + + \Tester\Assert::same('median_absolute_deviation_rating', $mad->key()); + } + + + public function testCreate(): void + { + $mad = new \Spameri\ElasticQuery\Aggregation\MedianAbsoluteDeviation('rating'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'rating_mad', + null, + $mad, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MedianAbsoluteDeviation())->run(); From 5ea6ee3980140143ade9b8fa87703b2bfb913e43 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:00:37 +0200 Subject: [PATCH 09/97] feat(aggregation): add string stats aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/StringStats.php | 45 ++++++++ .../ElasticQuery/Aggregation/StringStats.phpt | 109 ++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 src/Aggregation/StringStats.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/StringStats.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 89cce5f..8e7b9fd 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -170,6 +170,19 @@ Computes a robust measure of variability via the median of absolute deviations f new \Spameri\ElasticQuery\Aggregation\MedianAbsoluteDeviation(field: 'rating'); ``` +##### StringStats Aggregation +Computes statistics over string values (length, character distribution). +- Class: `\Spameri\ElasticQuery\Aggregation\StringStats` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-string-stats-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/StringStats.php) + +```php +new \Spameri\ElasticQuery\Aggregation\StringStats( + field: 'message.keyword', + showDistribution: true, // Optional, include per-character frequencies +); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/StringStats.php b/src/Aggregation/StringStats.php new file mode 100644 index 0000000..2f79d22 --- /dev/null +++ b/src/Aggregation/StringStats.php @@ -0,0 +1,45 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->showDistribution === true) { + $array['show_distribution'] = true; + } + + return [ + 'string_stats' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/StringStats.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/StringStats.phpt new file mode 100644 index 0000000..288df97 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/StringStats.phpt @@ -0,0 +1,109 @@ +toArray(); + + \Tester\Assert::same('message.keyword', $array['string_stats']['field']); + \Tester\Assert::false(isset($array['string_stats']['show_distribution'])); + } + + + public function testToArrayWithDistribution(): void + { + $stringStats = new \Spameri\ElasticQuery\Aggregation\StringStats('message.keyword', true); + + $array = $stringStats->toArray(); + + \Tester\Assert::true($array['string_stats']['show_distribution']); + } + + + public function testKey(): void + { + $stringStats = new \Spameri\ElasticQuery\Aggregation\StringStats('message.keyword'); + + \Tester\Assert::same('string_stats_message.keyword', $stringStats->key()); + } + + + public function testCreate(): void + { + $stringStats = new \Spameri\ElasticQuery\Aggregation\StringStats('message.keyword'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'message_stats', + null, + $stringStats, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new StringStats())->run(); From 1e205ab98cf61d2df31641c91426595198913815 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:01:15 +0200 Subject: [PATCH 10/97] feat(aggregation): add boxplot aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/BoxPlot.php | 45 ++++++++ .../ElasticQuery/Aggregation/BoxPlot.phpt | 109 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/Aggregation/BoxPlot.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/BoxPlot.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 8e7b9fd..87a3e63 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -183,6 +183,16 @@ new \Spameri\ElasticQuery\Aggregation\StringStats( ); ``` +##### BoxPlot Aggregation +Computes min, max, median and quartiles for plotting box-and-whisker diagrams. +- Class: `\Spameri\ElasticQuery\Aggregation\BoxPlot` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-boxplot-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/BoxPlot.php) + +```php +new \Spameri\ElasticQuery\Aggregation\BoxPlot(field: 'load_time'); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/BoxPlot.php b/src/Aggregation/BoxPlot.php new file mode 100644 index 0000000..b0eb2c9 --- /dev/null +++ b/src/Aggregation/BoxPlot.php @@ -0,0 +1,45 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->compression !== null) { + $array['compression'] = $this->compression; + } + + return [ + 'boxplot' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/BoxPlot.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/BoxPlot.phpt new file mode 100644 index 0000000..9fe0272 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/BoxPlot.phpt @@ -0,0 +1,109 @@ +toArray(); + + \Tester\Assert::same('load_time', $array['boxplot']['field']); + \Tester\Assert::false(isset($array['boxplot']['compression'])); + } + + + public function testToArrayWithCompression(): void + { + $boxPlot = new \Spameri\ElasticQuery\Aggregation\BoxPlot('load_time', 200); + + $array = $boxPlot->toArray(); + + \Tester\Assert::same(200, $array['boxplot']['compression']); + } + + + public function testKey(): void + { + $boxPlot = new \Spameri\ElasticQuery\Aggregation\BoxPlot('load_time'); + + \Tester\Assert::same('boxplot_load_time', $boxPlot->key()); + } + + + public function testCreate(): void + { + $boxPlot = new \Spameri\ElasticQuery\Aggregation\BoxPlot('load_time'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'load_time_boxplot', + null, + $boxPlot, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new BoxPlot())->run(); From b4992ba0622b433888f2113f9f3bec1c0d7a241d Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:01:47 +0200 Subject: [PATCH 11/97] feat(aggregation): add geo centroid aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/GeoCentroid.php | 38 +++++++ .../ElasticQuery/Aggregation/GeoCentroid.phpt | 98 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/Aggregation/GeoCentroid.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/GeoCentroid.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 87a3e63..89a4851 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -193,6 +193,16 @@ Computes min, max, median and quartiles for plotting box-and-whisker diagrams. new \Spameri\ElasticQuery\Aggregation\BoxPlot(field: 'load_time'); ``` +##### GeoCentroid Aggregation +Computes the weighted centroid of a set of geo points. +- Class: `\Spameri\ElasticQuery\Aggregation\GeoCentroid` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-geocentroid-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/GeoCentroid.php) + +```php +new \Spameri\ElasticQuery\Aggregation\GeoCentroid(field: 'location'); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/GeoCentroid.php b/src/Aggregation/GeoCentroid.php new file mode 100644 index 0000000..8b5db75 --- /dev/null +++ b/src/Aggregation/GeoCentroid.php @@ -0,0 +1,38 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'geo_centroid' => [ + 'field' => $this->field, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/GeoCentroid.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/GeoCentroid.phpt new file mode 100644 index 0000000..f0e0136 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/GeoCentroid.phpt @@ -0,0 +1,98 @@ +toArray(); + + \Tester\Assert::same('location', $array['geo_centroid']['field']); + } + + + public function testKey(): void + { + $geoCentroid = new \Spameri\ElasticQuery\Aggregation\GeoCentroid('location'); + + \Tester\Assert::same('geo_centroid_location', $geoCentroid->key()); + } + + + public function testCreate(): void + { + $geoCentroid = new \Spameri\ElasticQuery\Aggregation\GeoCentroid('location'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'centroid', + null, + $geoCentroid, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GeoCentroid())->run(); From 68225c3a2a627eda85e7419ccd8f831c9dc5e132 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:02:21 +0200 Subject: [PATCH 12/97] feat(aggregation): add geo bounds aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/GeoBounds.php | 45 ++++++++ .../ElasticQuery/Aggregation/GeoBounds.phpt | 109 ++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 src/Aggregation/GeoBounds.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/GeoBounds.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 89a4851..ada4fc9 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -203,6 +203,19 @@ Computes the weighted centroid of a set of geo points. new \Spameri\ElasticQuery\Aggregation\GeoCentroid(field: 'location'); ``` +##### GeoBounds Aggregation +Computes the bounding box of all matching geo points. +- Class: `\Spameri\ElasticQuery\Aggregation\GeoBounds` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-geobounds-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/GeoBounds.php) + +```php +new \Spameri\ElasticQuery\Aggregation\GeoBounds( + field: 'location', + wrapLongitude: true, // Default true, allow boxes crossing the dateline +); +``` + ##### Cardinality Aggregation Approximate count of distinct values using HyperLogLog++. - Class: `\Spameri\ElasticQuery\Aggregation\Cardinality` diff --git a/src/Aggregation/GeoBounds.php b/src/Aggregation/GeoBounds.php new file mode 100644 index 0000000..3c5309b --- /dev/null +++ b/src/Aggregation/GeoBounds.php @@ -0,0 +1,45 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->wrapLongitude === false) { + $array['wrap_longitude'] = false; + } + + return [ + 'geo_bounds' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/GeoBounds.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/GeoBounds.phpt new file mode 100644 index 0000000..ee51214 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/GeoBounds.phpt @@ -0,0 +1,109 @@ +toArray(); + + \Tester\Assert::same('location', $array['geo_bounds']['field']); + \Tester\Assert::false(isset($array['geo_bounds']['wrap_longitude'])); + } + + + public function testToArrayWithoutWrap(): void + { + $geoBounds = new \Spameri\ElasticQuery\Aggregation\GeoBounds('location', false); + + $array = $geoBounds->toArray(); + + \Tester\Assert::false($array['geo_bounds']['wrap_longitude']); + } + + + public function testKey(): void + { + $geoBounds = new \Spameri\ElasticQuery\Aggregation\GeoBounds('location'); + + \Tester\Assert::same('geo_bounds_location', $geoBounds->key()); + } + + + public function testCreate(): void + { + $geoBounds = new \Spameri\ElasticQuery\Aggregation\GeoBounds('location'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'bounds', + null, + $geoBounds, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GeoBounds())->run(); From 72ca67cd9698197778fa497df505cc6fd38c52bc Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:03:08 +0200 Subject: [PATCH 13/97] feat(aggregation): add date histogram aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 16 ++ src/Aggregation/DateHistogram.php | 81 +++++++++ .../Aggregation/DateHistogram.phpt | 154 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 src/Aggregation/DateHistogram.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/DateHistogram.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index ada4fc9..4e23da5 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -301,6 +301,22 @@ $filterAgg = new \Spameri\ElasticQuery\Aggregation\Filter(); $filterAgg->must()->add(new \Spameri\ElasticQuery\Query\Term('status', 'published')); ``` +##### DateHistogram Aggregation +Groups documents into date-based intervals (calendar or fixed). +- Class: `\Spameri\ElasticQuery\Aggregation\DateHistogram` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/DateHistogram.php) + +```php +new \Spameri\ElasticQuery\Aggregation\DateHistogram( + field: 'created_at', + calendarInterval: 'month', // or fixedInterval: '7d' + format: 'yyyy-MM-dd', + timeZone: 'Europe/Prague', + minDocCount: 1, +); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/DateHistogram.php b/src/Aggregation/DateHistogram.php new file mode 100644 index 0000000..e14507b --- /dev/null +++ b/src/Aggregation/DateHistogram.php @@ -0,0 +1,81 @@ +calendarInterval === null && $this->fixedInterval === null) { + throw new \InvalidArgumentException( + 'Either calendarInterval or fixedInterval must be provided.', + ); + } + + if ($this->calendarInterval !== null && $this->fixedInterval !== null) { + throw new \InvalidArgumentException( + 'Only one of calendarInterval or fixedInterval may be provided.', + ); + } + } + + + public function key(): string + { + return 'date_histogram_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->calendarInterval !== null) { + $array['calendar_interval'] = $this->calendarInterval; + } + + if ($this->fixedInterval !== null) { + $array['fixed_interval'] = $this->fixedInterval; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + if ($this->timeZone !== null) { + $array['time_zone'] = $this->timeZone; + } + + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + if ($this->offset !== null) { + $array['offset'] = $this->offset; + } + + return [ + 'date_histogram' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/DateHistogram.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/DateHistogram.phpt new file mode 100644 index 0000000..3bb5cf3 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/DateHistogram.phpt @@ -0,0 +1,154 @@ +toArray(); + + \Tester\Assert::same('created_at', $array['date_histogram']['field']); + \Tester\Assert::same('month', $array['date_histogram']['calendar_interval']); + \Tester\Assert::false(isset($array['date_histogram']['fixed_interval'])); + } + + + public function testToArrayFixedInterval(): void + { + $dateHistogram = new \Spameri\ElasticQuery\Aggregation\DateHistogram( + field: 'created_at', + fixedInterval: '7d', + format: 'yyyy-MM-dd', + timeZone: 'Europe/Prague', + minDocCount: 1, + ); + + $array = $dateHistogram->toArray(); + + \Tester\Assert::same('7d', $array['date_histogram']['fixed_interval']); + \Tester\Assert::same('yyyy-MM-dd', $array['date_histogram']['format']); + \Tester\Assert::same('Europe/Prague', $array['date_histogram']['time_zone']); + \Tester\Assert::same(1, $array['date_histogram']['min_doc_count']); + } + + + public function testRequiresInterval(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\DateHistogram('created_at'); + }, + \InvalidArgumentException::class, + ); + } + + + public function testRejectsBothIntervals(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\DateHistogram( + field: 'created_at', + calendarInterval: 'month', + fixedInterval: '30d', + ); + }, + \InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $dateHistogram = new \Spameri\ElasticQuery\Aggregation\DateHistogram( + field: 'created_at', + calendarInterval: 'month', + ); + + \Tester\Assert::same('date_histogram_created_at', $dateHistogram->key()); + } + + + public function testCreate(): void + { + $dateHistogram = new \Spameri\ElasticQuery\Aggregation\DateHistogram( + field: 'created_at', + calendarInterval: 'month', + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'over_time', + null, + $dateHistogram, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new DateHistogram())->run(); From 5314aaa374317ec9ad5d3693f367a9fd09a0deec Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:03:56 +0200 Subject: [PATCH 14/97] feat(aggregation): add date range aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 19 +++ src/Aggregation/DateRange.php | 66 ++++++++++ .../ElasticQuery/Aggregation/DateRange.phpt | 115 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/Aggregation/DateRange.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/DateRange.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 4e23da5..258d268 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -317,6 +317,25 @@ new \Spameri\ElasticQuery\Aggregation\DateHistogram( ); ``` +##### DateRange Aggregation +Groups documents into date ranges (accepts relative dates like `now-1M/M`). +- Class: `\Spameri\ElasticQuery\Aggregation\DateRange` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-daterange-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/DateRange.php) + +```php +$ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( + new \Spameri\ElasticQuery\Aggregation\RangeValue('past', null, 'now-1M/M'), + new \Spameri\ElasticQuery\Aggregation\RangeValue('recent', 'now-1M/M', null), +); + +new \Spameri\ElasticQuery\Aggregation\DateRange( + field: 'created_at', + ranges: $ranges, + format: 'MM-yyyy', +); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/DateRange.php b/src/Aggregation/DateRange.php new file mode 100644 index 0000000..b866ee1 --- /dev/null +++ b/src/Aggregation/DateRange.php @@ -0,0 +1,66 @@ +field; + } + + + public function ranges(): \Spameri\ElasticQuery\Aggregation\RangeValueCollection + { + return $this->ranges; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + if ($this->timeZone !== null) { + $array['time_zone'] = $this->timeZone; + } + + if ($this->keyed === true) { + $array['keyed'] = true; + } + + foreach ($this->ranges as $range) { + $array['ranges'][] = $range->toArray(); + } + + return [ + 'date_range' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/DateRange.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/DateRange.phpt new file mode 100644 index 0000000..02c16e1 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/DateRange.phpt @@ -0,0 +1,115 @@ +toArray(); + + \Tester\Assert::same('created_at', $array['date_range']['field']); + \Tester\Assert::same('MM-yyyy', $array['date_range']['format']); + \Tester\Assert::count(2, $array['date_range']['ranges']); + } + + + public function testKey(): void + { + $dateRange = new \Spameri\ElasticQuery\Aggregation\DateRange('created_at'); + + \Tester\Assert::same('date_range_created_at', $dateRange->key()); + } + + + public function testCreate(): void + { + $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( + new \Spameri\ElasticQuery\Aggregation\RangeValue('past', null, 'now-1M/M'), + new \Spameri\ElasticQuery\Aggregation\RangeValue('recent', 'now-1M/M', null), + ); + $dateRange = new \Spameri\ElasticQuery\Aggregation\DateRange( + field: 'created_at', + ranges: $ranges, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'when', + null, + $dateRange, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new DateRange())->run(); From 2122bb92abd7848a0cbcc56838ca703369d36432 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:04:28 +0200 Subject: [PATCH 15/97] feat(aggregation): add missing aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/Missing.php | 38 +++++++ .../ElasticQuery/Aggregation/Missing.phpt | 98 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/Aggregation/Missing.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Missing.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 258d268..284355b 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -336,6 +336,16 @@ new \Spameri\ElasticQuery\Aggregation\DateRange( ); ``` +##### Missing Aggregation +Single bucket containing documents missing a field value. +- Class: `\Spameri\ElasticQuery\Aggregation\Missing` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-missing-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Missing.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Missing(field: 'price'); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/Missing.php b/src/Aggregation/Missing.php new file mode 100644 index 0000000..16848d2 --- /dev/null +++ b/src/Aggregation/Missing.php @@ -0,0 +1,38 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'missing' => [ + 'field' => $this->field, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Missing.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Missing.phpt new file mode 100644 index 0000000..92c846a --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Missing.phpt @@ -0,0 +1,98 @@ +toArray(); + + \Tester\Assert::same('price', $array['missing']['field']); + } + + + public function testKey(): void + { + $missing = new \Spameri\ElasticQuery\Aggregation\Missing('price'); + + \Tester\Assert::same('missing_price', $missing->key()); + } + + + public function testCreate(): void + { + $missing = new \Spameri\ElasticQuery\Aggregation\Missing('price'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'no_price', + null, + $missing, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Missing())->run(); From 7da0e02d832fe266fdbc55d6edcdc44353ed02ef Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:05:01 +0200 Subject: [PATCH 16/97] feat(aggregation): add global aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/GlobalAggregation.php | 36 +++++++ .../Aggregation/GlobalAggregation.phpt | 98 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/Aggregation/GlobalAggregation.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/GlobalAggregation.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 284355b..b56e131 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -346,6 +346,16 @@ Single bucket containing documents missing a field value. new \Spameri\ElasticQuery\Aggregation\Missing(field: 'price'); ``` +##### Global Aggregation +Single bucket containing all documents, ignoring the current query. +- Class: `\Spameri\ElasticQuery\Aggregation\GlobalAggregation` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-global-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/GlobalAggregation.php) + +```php +new \Spameri\ElasticQuery\Aggregation\GlobalAggregation(); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/GlobalAggregation.php b/src/Aggregation/GlobalAggregation.php new file mode 100644 index 0000000..cdbc6b0 --- /dev/null +++ b/src/Aggregation/GlobalAggregation.php @@ -0,0 +1,36 @@ +key; + } + + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'global' => new \stdClass(), + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/GlobalAggregation.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/GlobalAggregation.phpt new file mode 100644 index 0000000..26e5be1 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/GlobalAggregation.phpt @@ -0,0 +1,98 @@ +toArray(); + + \Tester\Assert::true(isset($array['global'])); + \Tester\Assert::type(\stdClass::class, $array['global']); + } + + + public function testKey(): void + { + \Tester\Assert::same('global', (new \Spameri\ElasticQuery\Aggregation\GlobalAggregation())->key()); + \Tester\Assert::same('all_docs', (new \Spameri\ElasticQuery\Aggregation\GlobalAggregation('all_docs'))->key()); + } + + + public function testCreate(): void + { + $global = new \Spameri\ElasticQuery\Aggregation\GlobalAggregation(); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'all', + null, + $global, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GlobalAggregation())->run(); From 955a5e2cca0520c5c5fe7cce0a5379682499fa1c Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:05:38 +0200 Subject: [PATCH 17/97] feat(aggregation): add significant terms aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 14 +++ src/Aggregation/SignificantTerms.php | 50 ++++++++ .../Aggregation/SignificantTerms.phpt | 110 ++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 src/Aggregation/SignificantTerms.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/SignificantTerms.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index b56e131..5e7b226 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -356,6 +356,20 @@ Single bucket containing all documents, ignoring the current query. new \Spameri\ElasticQuery\Aggregation\GlobalAggregation(); ``` +##### SignificantTerms Aggregation +Finds terms that occur unusually often within the query context vs the index as a whole. +- Class: `\Spameri\ElasticQuery\Aggregation\SignificantTerms` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-significantterms-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/SignificantTerms.php) + +```php +new \Spameri\ElasticQuery\Aggregation\SignificantTerms( + field: 'crime_type', + size: 10, + minDocCount: 5, +); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/SignificantTerms.php b/src/Aggregation/SignificantTerms.php new file mode 100644 index 0000000..e175a28 --- /dev/null +++ b/src/Aggregation/SignificantTerms.php @@ -0,0 +1,50 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + return [ + 'significant_terms' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/SignificantTerms.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/SignificantTerms.phpt new file mode 100644 index 0000000..d3b26ea --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/SignificantTerms.phpt @@ -0,0 +1,110 @@ +toArray(); + + \Tester\Assert::same('crime_type', $array['significant_terms']['field']); + \Tester\Assert::false(isset($array['significant_terms']['size'])); + } + + + public function testToArrayWithSize(): void + { + $significant = new \Spameri\ElasticQuery\Aggregation\SignificantTerms('crime_type', 10, 5); + + $array = $significant->toArray(); + + \Tester\Assert::same(10, $array['significant_terms']['size']); + \Tester\Assert::same(5, $array['significant_terms']['min_doc_count']); + } + + + public function testKey(): void + { + $significant = new \Spameri\ElasticQuery\Aggregation\SignificantTerms('crime_type'); + + \Tester\Assert::same('significant_terms_crime_type', $significant->key()); + } + + + public function testCreate(): void + { + $significant = new \Spameri\ElasticQuery\Aggregation\SignificantTerms('crime_type'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'unusual_crimes', + null, + $significant, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SignificantTerms())->run(); From b5592ddb1bbbab87d15a61fb7b1d79bf807c7088 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:06:12 +0200 Subject: [PATCH 18/97] feat(aggregation): add significant text aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 14 +++ src/Aggregation/SignificantText.php | 50 ++++++++ .../Aggregation/SignificantText.phpt | 114 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/Aggregation/SignificantText.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/SignificantText.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 5e7b226..609c85d 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -370,6 +370,20 @@ new \Spameri\ElasticQuery\Aggregation\SignificantTerms( ); ``` +##### SignificantText Aggregation +Like significant terms but optimised for free-text fields. +- Class: `\Spameri\ElasticQuery\Aggregation\SignificantText` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-significanttext-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/SignificantText.php) + +```php +new \Spameri\ElasticQuery\Aggregation\SignificantText( + field: 'content', + size: 20, + filterDuplicateText: true, +); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/SignificantText.php b/src/Aggregation/SignificantText.php new file mode 100644 index 0000000..ef2ce48 --- /dev/null +++ b/src/Aggregation/SignificantText.php @@ -0,0 +1,50 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->filterDuplicateText === true) { + $array['filter_duplicate_text'] = true; + } + + return [ + 'significant_text' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/SignificantText.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/SignificantText.phpt new file mode 100644 index 0000000..8922f3d --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/SignificantText.phpt @@ -0,0 +1,114 @@ +toArray(); + + \Tester\Assert::same('content', $array['significant_text']['field']); + \Tester\Assert::false(isset($array['significant_text']['filter_duplicate_text'])); + } + + + public function testToArrayWithDuplicateFilter(): void + { + $significant = new \Spameri\ElasticQuery\Aggregation\SignificantText( + field: 'content', + size: 20, + filterDuplicateText: true, + ); + + $array = $significant->toArray(); + + \Tester\Assert::same(20, $array['significant_text']['size']); + \Tester\Assert::true($array['significant_text']['filter_duplicate_text']); + } + + + public function testKey(): void + { + $significant = new \Spameri\ElasticQuery\Aggregation\SignificantText('content'); + + \Tester\Assert::same('significant_text_content', $significant->key()); + } + + + public function testCreate(): void + { + $significant = new \Spameri\ElasticQuery\Aggregation\SignificantText('content'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'keywords', + null, + $significant, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SignificantText())->run(); From 93bac42148edcb6e05b7df394a21e6258a53c4ed Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:07:07 +0200 Subject: [PATCH 19/97] feat(aggregation): add geo distance aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 21 +++ src/Aggregation/GeoDistance.php | 67 ++++++++++ .../ElasticQuery/Aggregation/GeoDistance.phpt | 125 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/Aggregation/GeoDistance.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/GeoDistance.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 609c85d..bfb8467 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -384,6 +384,27 @@ new \Spameri\ElasticQuery\Aggregation\SignificantText( ); ``` +##### GeoDistance Aggregation +Groups documents into concentric distance buckets around an origin point. +- Class: `\Spameri\ElasticQuery\Aggregation\GeoDistance` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geodistance-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/GeoDistance.php) + +```php +$ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( + new \Spameri\ElasticQuery\Aggregation\RangeValue('near', null, 100), + new \Spameri\ElasticQuery\Aggregation\RangeValue('far', 100, null), +); + +new \Spameri\ElasticQuery\Aggregation\GeoDistance( + field: 'location', + lat: 50.0, + lon: 14.4, + ranges: $ranges, + unit: 'km', +); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/GeoDistance.php b/src/Aggregation/GeoDistance.php new file mode 100644 index 0000000..da58905 --- /dev/null +++ b/src/Aggregation/GeoDistance.php @@ -0,0 +1,67 @@ +field; + } + + + public function ranges(): \Spameri\ElasticQuery\Aggregation\RangeValueCollection + { + return $this->ranges; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + 'origin' => [ + 'lat' => $this->lat, + 'lon' => $this->lon, + ], + ]; + + if ($this->unit !== null) { + $array['unit'] = $this->unit; + } + + if ($this->distanceType !== null) { + $array['distance_type'] = $this->distanceType; + } + + foreach ($this->ranges as $range) { + $array['ranges'][] = $range->toArray(); + } + + return [ + 'geo_distance' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/GeoDistance.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/GeoDistance.phpt new file mode 100644 index 0000000..5a34b0f --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/GeoDistance.phpt @@ -0,0 +1,125 @@ +toArray(); + + \Tester\Assert::same('location', $array['geo_distance']['field']); + \Tester\Assert::same(50.0, $array['geo_distance']['origin']['lat']); + \Tester\Assert::same(14.4, $array['geo_distance']['origin']['lon']); + \Tester\Assert::same('km', $array['geo_distance']['unit']); + \Tester\Assert::count(2, $array['geo_distance']['ranges']); + } + + + public function testKey(): void + { + $geoDistance = new \Spameri\ElasticQuery\Aggregation\GeoDistance( + field: 'location', + lat: 50.0, + lon: 14.4, + ); + + \Tester\Assert::same('geo_distance_location', $geoDistance->key()); + } + + + public function testCreate(): void + { + $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( + new \Spameri\ElasticQuery\Aggregation\RangeValue('near', null, 100), + ); + $geoDistance = new \Spameri\ElasticQuery\Aggregation\GeoDistance( + field: 'location', + lat: 50.0, + lon: 14.4, + ranges: $ranges, + unit: 'km', + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'rings', + null, + $geoDistance, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GeoDistance())->run(); From 092b56d3fe8a32b41370cacfbb825fb3921ce9fa Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:07:42 +0200 Subject: [PATCH 20/97] feat(aggregation): add geohash grid aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/GeoHashGrid.php | 55 +++++++++ .../ElasticQuery/Aggregation/GeoHashGrid.phpt | 107 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/Aggregation/GeoHashGrid.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/GeoHashGrid.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index bfb8467..3fcbb01 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -405,6 +405,19 @@ new \Spameri\ElasticQuery\Aggregation\GeoDistance( ); ``` +##### GeoHashGrid Aggregation +Groups geo points into geohash-prefixed cells of configurable precision. +- Class: `\Spameri\ElasticQuery\Aggregation\GeoHashGrid` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/GeoHashGrid.php) + +```php +new \Spameri\ElasticQuery\Aggregation\GeoHashGrid( + field: 'location', + precision: 5, +); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/GeoHashGrid.php b/src/Aggregation/GeoHashGrid.php new file mode 100644 index 0000000..80be25a --- /dev/null +++ b/src/Aggregation/GeoHashGrid.php @@ -0,0 +1,55 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->precision !== null) { + $array['precision'] = $this->precision; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + return [ + 'geohash_grid' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/GeoHashGrid.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/GeoHashGrid.phpt new file mode 100644 index 0000000..5cb9794 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/GeoHashGrid.phpt @@ -0,0 +1,107 @@ +toArray(); + + \Tester\Assert::same('location', $array['geohash_grid']['field']); + \Tester\Assert::same(5, $array['geohash_grid']['precision']); + \Tester\Assert::same(100, $array['geohash_grid']['size']); + } + + + public function testKey(): void + { + $grid = new \Spameri\ElasticQuery\Aggregation\GeoHashGrid('location'); + + \Tester\Assert::same('geohash_grid_location', $grid->key()); + } + + + public function testCreate(): void + { + $grid = new \Spameri\ElasticQuery\Aggregation\GeoHashGrid( + field: 'location', + precision: 5, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'grid', + null, + $grid, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GeoHashGrid())->run(); From 7a56900746dcc8cebd7e59dd8bca82802fa1a09d Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:08:23 +0200 Subject: [PATCH 21/97] feat(aggregation): add geotile grid aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/GeoTileGrid.php | 55 +++++++++ .../ElasticQuery/Aggregation/GeoTileGrid.phpt | 105 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/Aggregation/GeoTileGrid.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/GeoTileGrid.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 3fcbb01..476941d 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -418,6 +418,19 @@ new \Spameri\ElasticQuery\Aggregation\GeoHashGrid( ); ``` +##### GeoTileGrid Aggregation +Groups geo points into map-tile cells (zoom levels 0–29). +- Class: `\Spameri\ElasticQuery\Aggregation\GeoTileGrid` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geotilegrid-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/GeoTileGrid.php) + +```php +new \Spameri\ElasticQuery\Aggregation\GeoTileGrid( + field: 'location', + precision: 8, +); +``` + ##### Nested Aggregation Aggregates on nested documents. - Class: `\Spameri\ElasticQuery\Aggregation\Nested` diff --git a/src/Aggregation/GeoTileGrid.php b/src/Aggregation/GeoTileGrid.php new file mode 100644 index 0000000..f14a64a --- /dev/null +++ b/src/Aggregation/GeoTileGrid.php @@ -0,0 +1,55 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->precision !== null) { + $array['precision'] = $this->precision; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + return [ + 'geotile_grid' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/GeoTileGrid.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/GeoTileGrid.phpt new file mode 100644 index 0000000..266eb38 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/GeoTileGrid.phpt @@ -0,0 +1,105 @@ +toArray(); + + \Tester\Assert::same('location', $array['geotile_grid']['field']); + \Tester\Assert::same(8, $array['geotile_grid']['precision']); + } + + + public function testKey(): void + { + $grid = new \Spameri\ElasticQuery\Aggregation\GeoTileGrid('location'); + + \Tester\Assert::same('geotile_grid_location', $grid->key()); + } + + + public function testCreate(): void + { + $grid = new \Spameri\ElasticQuery\Aggregation\GeoTileGrid( + field: 'location', + precision: 8, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'tiles', + null, + $grid, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GeoTileGrid())->run(); From 7df3f33be1b7b0ecaba58afd5bb303d3fa0b9995 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:09:01 +0200 Subject: [PATCH 22/97] feat(aggregation): add reverse nested aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 12 ++ src/Aggregation/ReverseNested.php | 44 +++++++ .../Aggregation/ReverseNested.phpt | 114 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/Aggregation/ReverseNested.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 476941d..7179630 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -441,6 +441,18 @@ Aggregates on nested documents. new \Spameri\ElasticQuery\Aggregation\Nested(path: 'comments'); ``` +##### ReverseNested Aggregation +Moves back from a nested context to the parent (or an ancestor at `path`). +- Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-reverse-nested-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/ReverseNested.php) + +```php +new \Spameri\ElasticQuery\Aggregation\ReverseNested(); +// Or back to a specific ancestor path: +new \Spameri\ElasticQuery\Aggregation\ReverseNested(path: 'parent_field'); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/ReverseNested.php b/src/Aggregation/ReverseNested.php new file mode 100644 index 0000000..a545e66 --- /dev/null +++ b/src/Aggregation/ReverseNested.php @@ -0,0 +1,44 @@ +path ?? 'root'); + } + + + /** + * @return array|\stdClass> + */ + public function toArray(): array + { + if ($this->path === null) { + return [ + 'reverse_nested' => new \stdClass(), + ]; + } + + return [ + 'reverse_nested' => [ + 'path' => $this->path, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt new file mode 100644 index 0000000..4251b23 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt @@ -0,0 +1,114 @@ +toArray(); + + \Tester\Assert::true(isset($array['reverse_nested'])); + \Tester\Assert::type(\stdClass::class, $array['reverse_nested']); + } + + + public function testToArrayWithPath(): void + { + $reverseNested = new \Spameri\ElasticQuery\Aggregation\ReverseNested('parent'); + + $array = $reverseNested->toArray(); + + \Tester\Assert::same('parent', $array['reverse_nested']['path']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'reverse_nested_root', + (new \Spameri\ElasticQuery\Aggregation\ReverseNested())->key(), + ); + \Tester\Assert::same( + 'reverse_nested_parent', + (new \Spameri\ElasticQuery\Aggregation\ReverseNested('parent'))->key(), + ); + } + + + public function testCreate(): void + { + $reverseNested = new \Spameri\ElasticQuery\Aggregation\ReverseNested(); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'back', + null, + $reverseNested, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new ReverseNested())->run(); From fbc8e9a561513be23ce727523fd96b8c90d974f6 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:10:07 +0200 Subject: [PATCH 23/97] feat(aggregation): add composite aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 16 +++ src/Aggregation/Composite.php | 74 ++++++++++ .../ElasticQuery/Aggregation/Composite.phpt | 126 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 src/Aggregation/Composite.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 7179630..7be7573 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -441,6 +441,22 @@ Aggregates on nested documents. new \Spameri\ElasticQuery\Aggregation\Nested(path: 'comments'); ``` +##### Composite Aggregation +Paginated multi-source buckets — useful for retrieving all unique value combinations. +- Class: `\Spameri\ElasticQuery\Aggregation\Composite` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-composite-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Composite.php) + +```php +$composite = new \Spameri\ElasticQuery\Aggregation\Composite( + key: 'my_buckets', + source: new \Spameri\ElasticQuery\Aggregation\Term('product'), + size: 100, +); +$composite->addSource(new \Spameri\ElasticQuery\Aggregation\Histogram('price', 50)); +// $composite->addSource(new \Spameri\ElasticQuery\Aggregation\DateHistogram('date', calendarInterval: 'day')); +``` + ##### ReverseNested Aggregation Moves back from a nested context to the parent (or an ancestor at `path`). - Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` diff --git a/src/Aggregation/Composite.php b/src/Aggregation/Composite.php new file mode 100644 index 0000000..9da4a3a --- /dev/null +++ b/src/Aggregation/Composite.php @@ -0,0 +1,74 @@ + + */ + private array $sources; + + + /** + * @param array|null $after + */ + public function __construct( + private string $key, + \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface $source, + private int|null $size = null, + private array|null $after = null, + ) + { + $this->sources = [$source->key() => $source]; + } + + + public function addSource( + \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface $source, + ): void + { + $this->sources[$source->key()] = $source; + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $sources = []; + foreach ($this->sources as $name => $source) { + $sources[] = [$name => $source->toArray()]; + } + + $array = [ + 'sources' => $sources, + ]; + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->after !== null) { + $array['after'] = $this->after; + } + + return [ + 'composite' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt new file mode 100644 index 0000000..1c6c049 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt @@ -0,0 +1,126 @@ +addSource(new \Spameri\ElasticQuery\Aggregation\Histogram('price', 50)); + + $array = $composite->toArray(); + + \Tester\Assert::same(100, $array['composite']['size']); + \Tester\Assert::count(2, $array['composite']['sources']); + \Tester\Assert::same('product', $array['composite']['sources'][0]['product']['terms']['field']); + \Tester\Assert::same('price', $array['composite']['sources'][1]['price']['histogram']['field']); + } + + + public function testToArrayWithAfter(): void + { + $composite = new \Spameri\ElasticQuery\Aggregation\Composite( + key: 'my_buckets', + source: new \Spameri\ElasticQuery\Aggregation\Term('product'), + after: ['product' => 'foo'], + ); + + $array = $composite->toArray(); + + \Tester\Assert::same(['product' => 'foo'], $array['composite']['after']); + } + + + public function testKey(): void + { + $composite = new \Spameri\ElasticQuery\Aggregation\Composite( + key: 'my_buckets', + source: new \Spameri\ElasticQuery\Aggregation\Term('product'), + ); + + \Tester\Assert::same('my_buckets', $composite->key()); + } + + + public function testCreate(): void + { + $composite = new \Spameri\ElasticQuery\Aggregation\Composite( + key: 'my_buckets', + source: new \Spameri\ElasticQuery\Aggregation\Term('product'), + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'composite_agg', + null, + $composite, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Composite())->run(); From 146ce28b3eafb72e1ffcd8eecc8cc0c7a8c242d1 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:10:46 +0200 Subject: [PATCH 24/97] feat(aggregation): add multi terms aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/MultiTerms.php | 54 +++++++++ .../ElasticQuery/Aggregation/MultiTerms.phpt | 106 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/Aggregation/MultiTerms.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/MultiTerms.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 7be7573..bfd85e9 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -457,6 +457,19 @@ $composite->addSource(new \Spameri\ElasticQuery\Aggregation\Histogram('price', 5 // $composite->addSource(new \Spameri\ElasticQuery\Aggregation\DateHistogram('date', calendarInterval: 'day')); ``` +##### MultiTerms Aggregation +Groups documents by the combination of values from multiple fields. +- Class: `\Spameri\ElasticQuery\Aggregation\MultiTerms` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-multi-terms-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/MultiTerms.php) + +```php +new \Spameri\ElasticQuery\Aggregation\MultiTerms( + terms: ['brand', 'color'], + size: 10, +); +``` + ##### ReverseNested Aggregation Moves back from a nested context to the parent (or an ancestor at `path`). - Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` diff --git a/src/Aggregation/MultiTerms.php b/src/Aggregation/MultiTerms.php new file mode 100644 index 0000000..a59b881 --- /dev/null +++ b/src/Aggregation/MultiTerms.php @@ -0,0 +1,54 @@ + $terms + */ + public function __construct( + private array $terms, + private int|null $size = null, + private string $key = 'multi_terms', + ) + { + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $termsArray = []; + foreach ($this->terms as $term) { + $termsArray[] = ['field' => $term]; + } + + $array = [ + 'terms' => $termsArray, + ]; + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + return [ + 'multi_terms' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/MultiTerms.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/MultiTerms.phpt new file mode 100644 index 0000000..ffc4350 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/MultiTerms.phpt @@ -0,0 +1,106 @@ +toArray(); + + \Tester\Assert::count(2, $array['multi_terms']['terms']); + \Tester\Assert::same('brand', $array['multi_terms']['terms'][0]['field']); + \Tester\Assert::same('color', $array['multi_terms']['terms'][1]['field']); + \Tester\Assert::same(10, $array['multi_terms']['size']); + } + + + public function testKey(): void + { + $multiTerms = new \Spameri\ElasticQuery\Aggregation\MultiTerms(['brand', 'color']); + + \Tester\Assert::same('multi_terms', $multiTerms->key()); + } + + + public function testCreate(): void + { + $multiTerms = new \Spameri\ElasticQuery\Aggregation\MultiTerms( + terms: ['brand', 'color'], + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'combos', + null, + $multiTerms, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MultiTerms())->run(); From 3966ec15950b8ec6f1f1ba26276a85e75fc6baa7 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:11:20 +0200 Subject: [PATCH 25/97] feat(aggregation): add rare terms aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 +++ src/Aggregation/RareTerms.php | 50 +++++++++ .../ElasticQuery/Aggregation/RareTerms.phpt | 102 ++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 src/Aggregation/RareTerms.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/RareTerms.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index bfd85e9..a0157af 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -470,6 +470,19 @@ new \Spameri\ElasticQuery\Aggregation\MultiTerms( ); ``` +##### RareTerms Aggregation +Finds terms that occur infrequently (the opposite of a terms-with-large-min-doc-count search). +- Class: `\Spameri\ElasticQuery\Aggregation\RareTerms` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-rare-terms-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/RareTerms.php) + +```php +new \Spameri\ElasticQuery\Aggregation\RareTerms( + field: 'genre', + maxDocCount: 2, +); +``` + ##### ReverseNested Aggregation Moves back from a nested context to the parent (or an ancestor at `path`). - Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` diff --git a/src/Aggregation/RareTerms.php b/src/Aggregation/RareTerms.php new file mode 100644 index 0000000..804fa0b --- /dev/null +++ b/src/Aggregation/RareTerms.php @@ -0,0 +1,50 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->maxDocCount !== null) { + $array['max_doc_count'] = $this->maxDocCount; + } + + if ($this->precision !== null) { + $array['precision'] = $this->precision; + } + + return [ + 'rare_terms' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/RareTerms.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/RareTerms.phpt new file mode 100644 index 0000000..09abbe5 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/RareTerms.phpt @@ -0,0 +1,102 @@ +toArray(); + + \Tester\Assert::same('genre', $array['rare_terms']['field']); + \Tester\Assert::same(2, $array['rare_terms']['max_doc_count']); + } + + + public function testKey(): void + { + $rare = new \Spameri\ElasticQuery\Aggregation\RareTerms('genre'); + + \Tester\Assert::same('rare_terms_genre', $rare->key()); + } + + + public function testCreate(): void + { + $rare = new \Spameri\ElasticQuery\Aggregation\RareTerms(field: 'genre'); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'rare_genres', + null, + $rare, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new RareTerms())->run(); From bd4c6e127bd33c4b289d5baf6b0aa131e051ad14 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:11:57 +0200 Subject: [PATCH 26/97] feat(aggregation): add sampler aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 10 ++ src/Aggregation/Sampler.php | 39 +++++++ .../ElasticQuery/Aggregation/Sampler.phpt | 103 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/Aggregation/Sampler.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Sampler.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index a0157af..615c7e9 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -483,6 +483,16 @@ new \Spameri\ElasticQuery\Aggregation\RareTerms( ); ``` +##### Sampler Aggregation +Limits sub-aggregations to top-N highest-scoring documents per shard. +- Class: `\Spameri\ElasticQuery\Aggregation\Sampler` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-sampler-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Sampler.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Sampler(shardSize: 200); +``` + ##### ReverseNested Aggregation Moves back from a nested context to the parent (or an ancestor at `path`). - Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` diff --git a/src/Aggregation/Sampler.php b/src/Aggregation/Sampler.php new file mode 100644 index 0000000..d49ae1c --- /dev/null +++ b/src/Aggregation/Sampler.php @@ -0,0 +1,39 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'sampler' => [ + 'shard_size' => $this->shardSize, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Sampler.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Sampler.phpt new file mode 100644 index 0000000..375dfe7 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Sampler.phpt @@ -0,0 +1,103 @@ +toArray(); + + \Tester\Assert::same(200, $array['sampler']['shard_size']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'sampler', + (new \Spameri\ElasticQuery\Aggregation\Sampler(100))->key(), + ); + \Tester\Assert::same( + 'top_sample', + (new \Spameri\ElasticQuery\Aggregation\Sampler(100, 'top_sample'))->key(), + ); + } + + + public function testCreate(): void + { + $sampler = new \Spameri\ElasticQuery\Aggregation\Sampler(shardSize: 100); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'sample', + null, + $sampler, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Sampler())->run(); From f1d51f8ddaa4bdc3a4b59700f90a4bdc58a835a8 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:12:32 +0200 Subject: [PATCH 27/97] feat(aggregation): add diversified sampler aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 14 +++ src/Aggregation/DiversifiedSampler.php | 48 ++++++++ .../Aggregation/DiversifiedSampler.phpt | 108 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/Aggregation/DiversifiedSampler.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/DiversifiedSampler.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 615c7e9..9c8bf6b 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -493,6 +493,20 @@ Limits sub-aggregations to top-N highest-scoring documents per shard. new \Spameri\ElasticQuery\Aggregation\Sampler(shardSize: 200); ``` +##### DiversifiedSampler Aggregation +Like sampler but limits documents-per-distinct-value to avoid skew. +- Class: `\Spameri\ElasticQuery\Aggregation\DiversifiedSampler` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-diversified-sampler-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/DiversifiedSampler.php) + +```php +new \Spameri\ElasticQuery\Aggregation\DiversifiedSampler( + field: 'author.keyword', + shardSize: 200, + maxDocsPerValue: 3, +); +``` + ##### ReverseNested Aggregation Moves back from a nested context to the parent (or an ancestor at `path`). - Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` diff --git a/src/Aggregation/DiversifiedSampler.php b/src/Aggregation/DiversifiedSampler.php new file mode 100644 index 0000000..08e7b8a --- /dev/null +++ b/src/Aggregation/DiversifiedSampler.php @@ -0,0 +1,48 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + 'shard_size' => $this->shardSize, + ]; + + if ($this->maxDocsPerValue !== null) { + $array['max_docs_per_value'] = $this->maxDocsPerValue; + } + + return [ + 'diversified_sampler' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/DiversifiedSampler.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/DiversifiedSampler.phpt new file mode 100644 index 0000000..756e9c1 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/DiversifiedSampler.phpt @@ -0,0 +1,108 @@ +toArray(); + + \Tester\Assert::same('author.keyword', $array['diversified_sampler']['field']); + \Tester\Assert::same(200, $array['diversified_sampler']['shard_size']); + \Tester\Assert::same(3, $array['diversified_sampler']['max_docs_per_value']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'diversified_sampler', + (new \Spameri\ElasticQuery\Aggregation\DiversifiedSampler('author.keyword'))->key(), + ); + } + + + public function testCreate(): void + { + $sampler = new \Spameri\ElasticQuery\Aggregation\DiversifiedSampler( + field: 'author.keyword', + shardSize: 100, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'sample', + null, + $sampler, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new DiversifiedSampler())->run(); From 48dc7ee558f11efce9cfc927d987f06dd3b585ff Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:13:17 +0200 Subject: [PATCH 28/97] feat(aggregation): add adjacency matrix aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 14 +++ src/Aggregation/AdjacencyMatrix.php | 63 ++++++++++ .../Aggregation/AdjacencyMatrix.phpt | 108 ++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 src/Aggregation/AdjacencyMatrix.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 9c8bf6b..7b8e7cc 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -507,6 +507,20 @@ new \Spameri\ElasticQuery\Aggregation\DiversifiedSampler( ); ``` +##### AdjacencyMatrix Aggregation +Buckets for each named filter and each pairwise intersection. +- Class: `\Spameri\ElasticQuery\Aggregation\AdjacencyMatrix` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-adjacency-matrix-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/AdjacencyMatrix.php) + +```php +$filterA = new \Spameri\ElasticQuery\Filter\FilterCollection(); +$filterA->must()->add(new \Spameri\ElasticQuery\Query\Term('status', 'active')); + +$matrix = new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix(); +$matrix->addFilter('group_a', $filterA); +``` + ##### ReverseNested Aggregation Moves back from a nested context to the parent (or an ancestor at `path`). - Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` diff --git a/src/Aggregation/AdjacencyMatrix.php b/src/Aggregation/AdjacencyMatrix.php new file mode 100644 index 0000000..c268089 --- /dev/null +++ b/src/Aggregation/AdjacencyMatrix.php @@ -0,0 +1,63 @@ + + */ + private array $filters; + + + public function __construct( + private string $key = 'adjacency_matrix', + ) + { + $this->filters = []; + } + + + public function addFilter( + string $name, + \Spameri\ElasticQuery\Filter\FilterCollection $filter, + ): void + { + $this->filters[$name] = $filter; + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $filters = []; + foreach ($this->filters as $name => $filter) { + $filterArray = $filter->toArray(); + if ($filterArray === []) { + $filterArray = ['must' => []]; + } + $filters[$name] = ['bool' => $filterArray]; + } + + return [ + 'adjacency_matrix' => [ + 'filters' => $filters, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt new file mode 100644 index 0000000..c56036c --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt @@ -0,0 +1,108 @@ +must()->add(new \Spameri\ElasticQuery\Query\Term('status', 'active')); + + $matrix = new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix(); + $matrix->addFilter('active', $filter); + + $array = $matrix->toArray(); + + \Tester\Assert::true(isset($array['adjacency_matrix']['filters']['active'])); + \Tester\Assert::true(isset($array['adjacency_matrix']['filters']['active']['bool'])); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'adjacency_matrix', + (new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix())->key(), + ); + } + + + public function testCreate(): void + { + $filterA = new \Spameri\ElasticQuery\Filter\FilterCollection(); + $filterA->must()->add(new \Spameri\ElasticQuery\Query\Term('status', 'active')); + + $matrix = new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix(); + $matrix->addFilter('group_a', $filterA); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'matrix', + null, + $matrix, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new AdjacencyMatrix())->run(); From 111e13ee1fc3e6244d263503009d4593f142505d Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:13:55 +0200 Subject: [PATCH 29/97] feat(aggregation): add ip range aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 18 +++ src/Aggregation/IpRange.php | 56 +++++++++ .../ElasticQuery/Aggregation/IpRange.phpt | 112 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 src/Aggregation/IpRange.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 7b8e7cc..2801771 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -521,6 +521,24 @@ $matrix = new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix(); $matrix->addFilter('group_a', $filterA); ``` +##### IpRange Aggregation +Groups IP-typed fields into ranges (accepts plain IPs or CIDR masks). +- Class: `\Spameri\ElasticQuery\Aggregation\IpRange` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-iprange-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/IpRange.php) + +```php +$ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( + new \Spameri\ElasticQuery\Aggregation\RangeValue('low', null, '10.0.0.5'), + new \Spameri\ElasticQuery\Aggregation\RangeValue('high', '10.0.0.5', null), +); + +new \Spameri\ElasticQuery\Aggregation\IpRange( + field: 'ip', + ranges: $ranges, +); +``` + ##### ReverseNested Aggregation Moves back from a nested context to the parent (or an ancestor at `path`). - Class: `\Spameri\ElasticQuery\Aggregation\ReverseNested` diff --git a/src/Aggregation/IpRange.php b/src/Aggregation/IpRange.php new file mode 100644 index 0000000..9667a18 --- /dev/null +++ b/src/Aggregation/IpRange.php @@ -0,0 +1,56 @@ +field; + } + + + public function ranges(): \Spameri\ElasticQuery\Aggregation\RangeValueCollection + { + return $this->ranges; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + ]; + + if ($this->keyed === true) { + $array['keyed'] = true; + } + + foreach ($this->ranges as $range) { + $array['ranges'][] = $range->toArray(); + } + + return [ + 'ip_range' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt new file mode 100644 index 0000000..919c7b9 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt @@ -0,0 +1,112 @@ +toArray(); + + \Tester\Assert::same('ip', $array['ip_range']['field']); + \Tester\Assert::count(2, $array['ip_range']['ranges']); + } + + + public function testKey(): void + { + $ipRange = new \Spameri\ElasticQuery\Aggregation\IpRange('ip'); + + \Tester\Assert::same('ip_range_ip', $ipRange->key()); + } + + + public function testCreate(): void + { + $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( + new \Spameri\ElasticQuery\Aggregation\RangeValue('private', null, '10.0.0.0'), + ); + $ipRange = new \Spameri\ElasticQuery\Aggregation\IpRange( + field: 'ip', + ranges: $ranges, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'ip_ranges', + null, + $ipRange, + ), + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + $elasticQuery->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new IpRange())->run(); From 47c262d37a7bdf32c646c1e58fd0af5a5a44a6d6 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:14:30 +0200 Subject: [PATCH 30/97] feat(aggregation): add avg bucket pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 18 +++++ src/Aggregation/AvgBucket.php | 51 +++++++++++++ .../ElasticQuery/Aggregation/AvgBucket.phpt | 75 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/Aggregation/AvgBucket.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/AvgBucket.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 2801771..094cf4c 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -553,6 +553,24 @@ new \Spameri\ElasticQuery\Aggregation\ReverseNested(path: 'parent_field'); --- +## Pipeline Aggregations + +Aggregations whose input is the output of other aggregations (referenced by `bucketsPath`). + +##### AvgBucket Aggregation +Average of values across sibling buckets. +- Class: `\Spameri\ElasticQuery\Aggregation\AvgBucket` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-avg-bucket-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/AvgBucket.php) + +```php +new \Spameri\ElasticQuery\Aggregation\AvgBucket( + bucketsPath: 'sales_per_month>sales', +); +``` + +--- + ## Aggregation Collections ##### AggregationCollection diff --git a/src/Aggregation/AvgBucket.php b/src/Aggregation/AvgBucket.php new file mode 100644 index 0000000..2913aa3 --- /dev/null +++ b/src/Aggregation/AvgBucket.php @@ -0,0 +1,51 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'avg_bucket' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/AvgBucket.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/AvgBucket.phpt new file mode 100644 index 0000000..11fe0e1 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/AvgBucket.phpt @@ -0,0 +1,75 @@ +sales', + ); + + $array = $avgBucket->toArray(); + + \Tester\Assert::same('sales_per_month>sales', $array['avg_bucket']['buckets_path']); + } + + + public function testToArrayWithGapPolicy(): void + { + $avgBucket = new \Spameri\ElasticQuery\Aggregation\AvgBucket( + bucketsPath: 'sales_per_month>sales', + gapPolicy: 'skip', + format: '0.00', + ); + + $array = $avgBucket->toArray(); + + \Tester\Assert::same('skip', $array['avg_bucket']['gap_policy']); + \Tester\Assert::same('0.00', $array['avg_bucket']['format']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'avg_bucket', + (new \Spameri\ElasticQuery\Aggregation\AvgBucket('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new AvgBucket())->run(); From cea99eb1552216006d7af2d3b6d40e7270c53d2a Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:15:00 +0200 Subject: [PATCH 31/97] feat(aggregation): add sum bucket pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 12 ++++ src/Aggregation/SumBucket.php | 51 ++++++++++++++++ .../ElasticQuery/Aggregation/SumBucket.phpt | 60 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/Aggregation/SumBucket.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/SumBucket.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 094cf4c..64e1c30 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -569,6 +569,18 @@ new \Spameri\ElasticQuery\Aggregation\AvgBucket( ); ``` +##### SumBucket Aggregation +Sum of values across sibling buckets. +- Class: `\Spameri\ElasticQuery\Aggregation\SumBucket` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-sum-bucket-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/SumBucket.php) + +```php +new \Spameri\ElasticQuery\Aggregation\SumBucket( + bucketsPath: 'sales_per_month>sales', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/SumBucket.php b/src/Aggregation/SumBucket.php new file mode 100644 index 0000000..36aa0cb --- /dev/null +++ b/src/Aggregation/SumBucket.php @@ -0,0 +1,51 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'sum_bucket' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/SumBucket.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/SumBucket.phpt new file mode 100644 index 0000000..56cec3f --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/SumBucket.phpt @@ -0,0 +1,60 @@ +sales', + ); + + $array = $sumBucket->toArray(); + + \Tester\Assert::same('sales_per_month>sales', $array['sum_bucket']['buckets_path']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'sum_bucket', + (new \Spameri\ElasticQuery\Aggregation\SumBucket('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SumBucket())->run(); From 7ffd196423f80a413ff3a7f9551ea8e13c045601 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:15:29 +0200 Subject: [PATCH 32/97] feat(aggregation): add max bucket pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 12 ++++ src/Aggregation/MaxBucket.php | 51 ++++++++++++++++ .../ElasticQuery/Aggregation/MaxBucket.phpt | 60 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/Aggregation/MaxBucket.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/MaxBucket.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 64e1c30..844f616 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -581,6 +581,18 @@ new \Spameri\ElasticQuery\Aggregation\SumBucket( ); ``` +##### MaxBucket Aggregation +Maximum value across sibling buckets. +- Class: `\Spameri\ElasticQuery\Aggregation\MaxBucket` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-max-bucket-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/MaxBucket.php) + +```php +new \Spameri\ElasticQuery\Aggregation\MaxBucket( + bucketsPath: 'sales_per_month>sales', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/MaxBucket.php b/src/Aggregation/MaxBucket.php new file mode 100644 index 0000000..846aacc --- /dev/null +++ b/src/Aggregation/MaxBucket.php @@ -0,0 +1,51 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'max_bucket' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/MaxBucket.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/MaxBucket.phpt new file mode 100644 index 0000000..afb3473 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/MaxBucket.phpt @@ -0,0 +1,60 @@ +sales', + ); + + $array = $maxBucket->toArray(); + + \Tester\Assert::same('sales_per_month>sales', $array['max_bucket']['buckets_path']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'max_bucket', + (new \Spameri\ElasticQuery\Aggregation\MaxBucket('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MaxBucket())->run(); From cdb97d1a137a1319fe4279a1637101e864da68d7 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:16:01 +0200 Subject: [PATCH 33/97] feat(aggregation): add min bucket pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 12 ++++ src/Aggregation/MinBucket.php | 51 ++++++++++++++++ .../ElasticQuery/Aggregation/MinBucket.phpt | 60 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/Aggregation/MinBucket.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/MinBucket.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 844f616..c293c61 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -593,6 +593,18 @@ new \Spameri\ElasticQuery\Aggregation\MaxBucket( ); ``` +##### MinBucket Aggregation +Minimum value across sibling buckets. +- Class: `\Spameri\ElasticQuery\Aggregation\MinBucket` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-min-bucket-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/MinBucket.php) + +```php +new \Spameri\ElasticQuery\Aggregation\MinBucket( + bucketsPath: 'sales_per_month>sales', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/MinBucket.php b/src/Aggregation/MinBucket.php new file mode 100644 index 0000000..ffaed8d --- /dev/null +++ b/src/Aggregation/MinBucket.php @@ -0,0 +1,51 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'min_bucket' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/MinBucket.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/MinBucket.phpt new file mode 100644 index 0000000..f9036b7 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/MinBucket.phpt @@ -0,0 +1,60 @@ +sales', + ); + + $array = $minBucket->toArray(); + + \Tester\Assert::same('sales_per_month>sales', $array['min_bucket']['buckets_path']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'min_bucket', + (new \Spameri\ElasticQuery\Aggregation\MinBucket('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MinBucket())->run(); From 13cd8cd46734165b809b6bd866cd7fe48c26aeea Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:16:30 +0200 Subject: [PATCH 34/97] feat(aggregation): add stats bucket pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 12 ++++ src/Aggregation/StatsBucket.php | 51 ++++++++++++++++ .../ElasticQuery/Aggregation/StatsBucket.phpt | 60 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/Aggregation/StatsBucket.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/StatsBucket.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index c293c61..1299d4b 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -605,6 +605,18 @@ new \Spameri\ElasticQuery\Aggregation\MinBucket( ); ``` +##### StatsBucket Aggregation +Stats (count/min/max/avg/sum) across sibling buckets. +- Class: `\Spameri\ElasticQuery\Aggregation\StatsBucket` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-stats-bucket-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/StatsBucket.php) + +```php +new \Spameri\ElasticQuery\Aggregation\StatsBucket( + bucketsPath: 'sales_per_month>sales', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/StatsBucket.php b/src/Aggregation/StatsBucket.php new file mode 100644 index 0000000..f80bd55 --- /dev/null +++ b/src/Aggregation/StatsBucket.php @@ -0,0 +1,51 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'stats_bucket' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/StatsBucket.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/StatsBucket.phpt new file mode 100644 index 0000000..d5b736f --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/StatsBucket.phpt @@ -0,0 +1,60 @@ +sales', + ); + + $array = $statsBucket->toArray(); + + \Tester\Assert::same('sales_per_month>sales', $array['stats_bucket']['buckets_path']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'stats_bucket', + (new \Spameri\ElasticQuery\Aggregation\StatsBucket('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new StatsBucket())->run(); From 9b2253876bab785373568cbf6e63d20ffff1817e Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:17:02 +0200 Subject: [PATCH 35/97] feat(aggregation): add percentiles bucket pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++++ src/Aggregation/PercentilesBucket.php | 59 ++++++++++++++++++ .../Aggregation/PercentilesBucket.phpt | 62 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 src/Aggregation/PercentilesBucket.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/PercentilesBucket.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 1299d4b..2f5c276 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -617,6 +617,19 @@ new \Spameri\ElasticQuery\Aggregation\StatsBucket( ); ``` +##### PercentilesBucket Aggregation +Percentile values across sibling buckets. +- Class: `\Spameri\ElasticQuery\Aggregation\PercentilesBucket` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-percentiles-bucket-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/PercentilesBucket.php) + +```php +new \Spameri\ElasticQuery\Aggregation\PercentilesBucket( + bucketsPath: 'sales_per_month>sales', + percents: [50, 95, 99], +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/PercentilesBucket.php b/src/Aggregation/PercentilesBucket.php new file mode 100644 index 0000000..4679370 --- /dev/null +++ b/src/Aggregation/PercentilesBucket.php @@ -0,0 +1,59 @@ + $percents + */ + public function __construct( + private string $bucketsPath, + private array $percents = [], + private string|null $gapPolicy = null, + private string|null $format = null, + private string $key = 'percentiles_bucket', + ) + { + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->percents !== []) { + $array['percents'] = $this->percents; + } + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'percentiles_bucket' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/PercentilesBucket.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/PercentilesBucket.phpt new file mode 100644 index 0000000..f320daa --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/PercentilesBucket.phpt @@ -0,0 +1,62 @@ +sales', + percents: [50, 95, 99], + ); + + $array = $pb->toArray(); + + \Tester\Assert::same('sales_per_month>sales', $array['percentiles_bucket']['buckets_path']); + \Tester\Assert::same([50, 95, 99], $array['percentiles_bucket']['percents']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'percentiles_bucket', + (new \Spameri\ElasticQuery\Aggregation\PercentilesBucket('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new PercentilesBucket())->run(); From c9401ec65f873ffea8046fe0f8207f753d78104e Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:17:32 +0200 Subject: [PATCH 36/97] feat(aggregation): add derivative pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++++ src/Aggregation/Derivative.php | 56 +++++++++++++++++ .../ElasticQuery/Aggregation/Derivative.phpt | 62 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/Aggregation/Derivative.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Derivative.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 2f5c276..7a2fe6f 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -630,6 +630,19 @@ new \Spameri\ElasticQuery\Aggregation\PercentilesBucket( ); ``` +##### Derivative Aggregation +Difference between successive bucket values. +- Class: `\Spameri\ElasticQuery\Aggregation\Derivative` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-derivative-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Derivative.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Derivative( + bucketsPath: 'sales', + unit: 'day', // For date histogram parents +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/Derivative.php b/src/Aggregation/Derivative.php new file mode 100644 index 0000000..940174a --- /dev/null +++ b/src/Aggregation/Derivative.php @@ -0,0 +1,56 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->unit !== null) { + $array['unit'] = $this->unit; + } + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'derivative' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Derivative.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Derivative.phpt new file mode 100644 index 0000000..5c6ff9a --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Derivative.phpt @@ -0,0 +1,62 @@ +toArray(); + + \Tester\Assert::same('sales', $array['derivative']['buckets_path']); + \Tester\Assert::same('day', $array['derivative']['unit']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'derivative', + (new \Spameri\ElasticQuery\Aggregation\Derivative('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Derivative())->run(); From 358e0974c84ef6c45b2b5945d0fb053ceb2bfd98 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:18:01 +0200 Subject: [PATCH 37/97] feat(aggregation): add cumulative sum pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 12 ++++ src/Aggregation/CumulativeSum.php | 46 ++++++++++++++ .../Aggregation/CumulativeSum.phpt | 60 +++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/Aggregation/CumulativeSum.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/CumulativeSum.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 7a2fe6f..e202174 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -643,6 +643,18 @@ new \Spameri\ElasticQuery\Aggregation\Derivative( ); ``` +##### CumulativeSum Aggregation +Running total of values across buckets. +- Class: `\Spameri\ElasticQuery\Aggregation\CumulativeSum` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-cumulative-sum-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/CumulativeSum.php) + +```php +new \Spameri\ElasticQuery\Aggregation\CumulativeSum( + bucketsPath: 'sales', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/CumulativeSum.php b/src/Aggregation/CumulativeSum.php new file mode 100644 index 0000000..99d4b35 --- /dev/null +++ b/src/Aggregation/CumulativeSum.php @@ -0,0 +1,46 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'cumulative_sum' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/CumulativeSum.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/CumulativeSum.phpt new file mode 100644 index 0000000..80eb466 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/CumulativeSum.phpt @@ -0,0 +1,60 @@ +toArray(); + + \Tester\Assert::same('sales', $array['cumulative_sum']['buckets_path']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'cumulative_sum', + (new \Spameri\ElasticQuery\Aggregation\CumulativeSum('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new CumulativeSum())->run(); From 3ec8af2f632bd77ea4753196f5da5cbad08c7ff4 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:18:32 +0200 Subject: [PATCH 38/97] feat(aggregation): add moving function pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 14 ++++ src/Aggregation/MovingFunction.php | 55 ++++++++++++++++ .../Aggregation/MovingFunction.phpt | 64 +++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 src/Aggregation/MovingFunction.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/MovingFunction.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index e202174..cc6e5b8 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -655,6 +655,20 @@ new \Spameri\ElasticQuery\Aggregation\CumulativeSum( ); ``` +##### MovingFunction Aggregation +Applies a Painless script over a sliding window of bucket values. +- Class: `\Spameri\ElasticQuery\Aggregation\MovingFunction` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-movfn-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/MovingFunction.php) + +```php +new \Spameri\ElasticQuery\Aggregation\MovingFunction( + bucketsPath: 'sales', + window: 5, + script: 'MovingFunctions.unweightedAvg(values)', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/MovingFunction.php b/src/Aggregation/MovingFunction.php new file mode 100644 index 0000000..ec80032 --- /dev/null +++ b/src/Aggregation/MovingFunction.php @@ -0,0 +1,55 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + 'window' => $this->window, + 'script' => $this->script, + ]; + + if ($this->shift !== null) { + $array['shift'] = $this->shift; + } + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + return [ + 'moving_fn' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/MovingFunction.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/MovingFunction.phpt new file mode 100644 index 0000000..ce6736b --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/MovingFunction.phpt @@ -0,0 +1,64 @@ +toArray(); + + \Tester\Assert::same('sales', $array['moving_fn']['buckets_path']); + \Tester\Assert::same(5, $array['moving_fn']['window']); + \Tester\Assert::same('MovingFunctions.unweightedAvg(values)', $array['moving_fn']['script']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'moving_fn', + (new \Spameri\ElasticQuery\Aggregation\MovingFunction('p', 5, 'script'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MovingFunction())->run(); From d9a4f6951faab91cf7e271e4a23ab0856d99b72d Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:19:03 +0200 Subject: [PATCH 39/97] feat(aggregation): add serial diff pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++++ src/Aggregation/SerialDiff.php | 56 +++++++++++++++++ .../ElasticQuery/Aggregation/SerialDiff.phpt | 62 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/Aggregation/SerialDiff.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/SerialDiff.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index cc6e5b8..6673f43 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -669,6 +669,19 @@ new \Spameri\ElasticQuery\Aggregation\MovingFunction( ); ``` +##### SerialDiff Aggregation +Difference between values N buckets apart — useful for removing seasonality. +- Class: `\Spameri\ElasticQuery\Aggregation\SerialDiff` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-serialdiff-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/SerialDiff.php) + +```php +new \Spameri\ElasticQuery\Aggregation\SerialDiff( + bucketsPath: 'sales', + lag: 7, // Week-over-week diff +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/SerialDiff.php b/src/Aggregation/SerialDiff.php new file mode 100644 index 0000000..3ec5eba --- /dev/null +++ b/src/Aggregation/SerialDiff.php @@ -0,0 +1,56 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->lag !== null) { + $array['lag'] = $this->lag; + } + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'serial_diff' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/SerialDiff.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/SerialDiff.phpt new file mode 100644 index 0000000..e9979e6 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/SerialDiff.phpt @@ -0,0 +1,62 @@ +toArray(); + + \Tester\Assert::same('sales', $array['serial_diff']['buckets_path']); + \Tester\Assert::same(7, $array['serial_diff']['lag']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'serial_diff', + (new \Spameri\ElasticQuery\Aggregation\SerialDiff('p'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SerialDiff())->run(); From c7be141eccff071ce643c01291de2a8f7978aed0 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:19:35 +0200 Subject: [PATCH 40/97] feat(aggregation): add bucket script pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++++ src/Aggregation/BucketScript.php | 56 +++++++++++++++++ .../Aggregation/BucketScript.phpt | 62 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/Aggregation/BucketScript.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/BucketScript.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 6673f43..deeffac 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -682,6 +682,19 @@ new \Spameri\ElasticQuery\Aggregation\SerialDiff( ); ``` +##### BucketScript Aggregation +Computes a per-bucket value via a Painless script over named sibling metrics. +- Class: `\Spameri\ElasticQuery\Aggregation\BucketScript` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-script-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/BucketScript.php) + +```php +new \Spameri\ElasticQuery\Aggregation\BucketScript( + bucketsPath: ['tShirts' => 't-shirts', 'total' => 'total_sales'], + script: 'params.tShirts / params.total * 100', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/BucketScript.php b/src/Aggregation/BucketScript.php new file mode 100644 index 0000000..8ef5b23 --- /dev/null +++ b/src/Aggregation/BucketScript.php @@ -0,0 +1,56 @@ + $bucketsPath Map of script-variable name to sibling agg path. + */ + public function __construct( + private array $bucketsPath, + private string $script, + private string|null $gapPolicy = null, + private string|null $format = null, + private string $key = 'bucket_script', + ) + { + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + 'script' => $this->script, + ]; + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'bucket_script' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/BucketScript.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/BucketScript.phpt new file mode 100644 index 0000000..48904fd --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/BucketScript.phpt @@ -0,0 +1,62 @@ + 't-shirts', 'total' => 'total_sales'], + script: 'params.tShirts / params.total * 100', + ); + + $array = $bs->toArray(); + + \Tester\Assert::same('t-shirts', $array['bucket_script']['buckets_path']['tShirts']); + \Tester\Assert::same('params.tShirts / params.total * 100', $array['bucket_script']['script']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'bucket_script', + (new \Spameri\ElasticQuery\Aggregation\BucketScript(['a' => 'b'], 'params.a'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new BucketScript())->run(); From f3e34addd6a8724cfb145bea5ef935f2c910fdc5 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:20:07 +0200 Subject: [PATCH 41/97] feat(aggregation): add bucket selector pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++++ src/Aggregation/BucketSelector.php | 51 +++++++++++++++ .../Aggregation/BucketSelector.phpt | 62 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/Aggregation/BucketSelector.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/BucketSelector.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index deeffac..4d81eb9 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -695,6 +695,19 @@ new \Spameri\ElasticQuery\Aggregation\BucketScript( ); ``` +##### BucketSelector Aggregation +Filters buckets to those whose script returns true. +- Class: `\Spameri\ElasticQuery\Aggregation\BucketSelector` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-selector-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/BucketSelector.php) + +```php +new \Spameri\ElasticQuery\Aggregation\BucketSelector( + bucketsPath: ['totalSales' => 'total_sales'], + script: 'params.totalSales > 100', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/BucketSelector.php b/src/Aggregation/BucketSelector.php new file mode 100644 index 0000000..d9d930e --- /dev/null +++ b/src/Aggregation/BucketSelector.php @@ -0,0 +1,51 @@ + $bucketsPath Map of script-variable name to sibling agg path. + */ + public function __construct( + private array $bucketsPath, + private string $script, + private string|null $gapPolicy = null, + private string $key = 'bucket_selector', + ) + { + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + 'script' => $this->script, + ]; + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + return [ + 'bucket_selector' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/BucketSelector.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/BucketSelector.phpt new file mode 100644 index 0000000..a9d6fa6 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/BucketSelector.phpt @@ -0,0 +1,62 @@ + 'total_sales'], + script: 'params.totalSales > 100', + ); + + $array = $selector->toArray(); + + \Tester\Assert::same('total_sales', $array['bucket_selector']['buckets_path']['totalSales']); + \Tester\Assert::same('params.totalSales > 100', $array['bucket_selector']['script']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'bucket_selector', + (new \Spameri\ElasticQuery\Aggregation\BucketSelector(['a' => 'b'], 'params.a'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new BucketSelector())->run(); From 4ab45faa3112147dc4e1b733778dcdd551985078 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:20:40 +0200 Subject: [PATCH 42/97] feat(aggregation): add bucket sort pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++++ src/Aggregation/BucketSort.php | 61 ++++++++++++++++++ .../ElasticQuery/Aggregation/BucketSort.phpt | 64 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 src/Aggregation/BucketSort.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/BucketSort.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 4d81eb9..6642138 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -708,6 +708,19 @@ new \Spameri\ElasticQuery\Aggregation\BucketSelector( ); ``` +##### BucketSort Aggregation +Sorts and truncates sibling buckets (also supports pagination). +- Class: `\Spameri\ElasticQuery\Aggregation\BucketSort` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-sort-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/BucketSort.php) + +```php +new \Spameri\ElasticQuery\Aggregation\BucketSort( + sort: [['total_sales' => ['order' => 'desc']]], + size: 5, +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/BucketSort.php b/src/Aggregation/BucketSort.php new file mode 100644 index 0000000..fcf4d79 --- /dev/null +++ b/src/Aggregation/BucketSort.php @@ -0,0 +1,61 @@ +> $sort Sort entries, e.g. [['total_sales' => ['order' => 'desc']]]. + */ + public function __construct( + private array $sort = [], + private int|null $size = null, + private int|null $from = null, + private string|null $gapPolicy = null, + private string $key = 'bucket_sort', + ) + { + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = []; + + if ($this->sort !== []) { + $array['sort'] = $this->sort; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->from !== null) { + $array['from'] = $this->from; + } + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + return [ + 'bucket_sort' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/BucketSort.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/BucketSort.phpt new file mode 100644 index 0000000..0074b3b --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/BucketSort.phpt @@ -0,0 +1,64 @@ + ['order' => 'desc']]], + size: 5, + from: 0, + ); + + $array = $bucketSort->toArray(); + + \Tester\Assert::same('desc', $array['bucket_sort']['sort'][0]['total_sales']['order']); + \Tester\Assert::same(5, $array['bucket_sort']['size']); + \Tester\Assert::same(0, $array['bucket_sort']['from']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'bucket_sort', + (new \Spameri\ElasticQuery\Aggregation\BucketSort())->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new BucketSort())->run(); From b6550510281e1f8913dce64c03433f22354f96a8 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:21:09 +0200 Subject: [PATCH 43/97] feat(aggregation): add normalize pipeline aggregation Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/03-aggregation-objects.md | 13 ++++ src/Aggregation/Normalize.php | 48 ++++++++++++++ .../ElasticQuery/Aggregation/Normalize.phpt | 62 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/Aggregation/Normalize.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Normalize.phpt diff --git a/doc/03-aggregation-objects.md b/doc/03-aggregation-objects.md index 6642138..1cafa7b 100644 --- a/doc/03-aggregation-objects.md +++ b/doc/03-aggregation-objects.md @@ -721,6 +721,19 @@ new \Spameri\ElasticQuery\Aggregation\BucketSort( ); ``` +##### Normalize Aggregation +Rescales bucket values (e.g. `percent_of_sum`, `rescale_0_1`, `mean`, `z-score`, `softmax`). +- Class: `\Spameri\ElasticQuery\Aggregation\Normalize` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-normalize-aggregation.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Aggregation/Normalize.php) + +```php +new \Spameri\ElasticQuery\Aggregation\Normalize( + bucketsPath: 'sales', + method: 'percent_of_sum', +); +``` + --- ## Aggregation Collections diff --git a/src/Aggregation/Normalize.php b/src/Aggregation/Normalize.php new file mode 100644 index 0000000..aa91b53 --- /dev/null +++ b/src/Aggregation/Normalize.php @@ -0,0 +1,48 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'buckets_path' => $this->bucketsPath, + 'method' => $this->method, + ]; + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return [ + 'normalize' => $array, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Normalize.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Normalize.phpt new file mode 100644 index 0000000..d30c037 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Normalize.phpt @@ -0,0 +1,62 @@ +toArray(); + + \Tester\Assert::same('sales', $array['normalize']['buckets_path']); + \Tester\Assert::same('percent_of_sum', $array['normalize']['method']); + } + + + public function testKey(): void + { + \Tester\Assert::same( + 'normalize', + (new \Spameri\ElasticQuery\Aggregation\Normalize('p', 'rescale_0_1'))->key(), + ); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Normalize())->run(); From bc41270076e0af52fb401cb45de52861c423fdf9 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:38:31 +0200 Subject: [PATCH 44/97] feat(query): add ids query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 10 ++ src/Query/Ids.php | 48 ++++++++ .../SpameriTests/ElasticQuery/Query/Ids.phpt | 105 ++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/Query/Ids.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Ids.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index d457672..e11632a 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -153,6 +153,16 @@ Match documents where a field has a value. new \Spameri\ElasticQuery\Query\Exists(field: 'description'); ``` +##### Ids Query +Fetch documents by their `_id`. +- Class: `\Spameri\ElasticQuery\Query\Ids` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-ids-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Ids.php) + +```php +new \Spameri\ElasticQuery\Query\Ids(values: ['1', '2', '3']); +``` + ##### WildCard Query Match using wildcard patterns (* and ?). - Class: `\Spameri\ElasticQuery\Query\WildCard` diff --git a/src/Query/Ids.php b/src/Query/Ids.php new file mode 100644 index 0000000..e99ebb2 --- /dev/null +++ b/src/Query/Ids.php @@ -0,0 +1,48 @@ + $values + */ + public function __construct( + private array $values, + private float $boost = 1.0, + ) + { + if ($values === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Ids query must contain at least one id.', + ); + } + } + + + public function key(): string + { + return 'ids_' . \implode('-', $this->values); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'ids' => [ + 'values' => $this->values, + 'boost' => $this->boost, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Ids.phpt b/tests/SpameriTests/ElasticQuery/Query/Ids.phpt new file mode 100644 index 0000000..6f288a4 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Ids.phpt @@ -0,0 +1,105 @@ +toArray(); + + \Tester\Assert::same(['1', '2', '3'], $array['ids']['values']); + } + + + public function testKey(): void + { + $ids = new \Spameri\ElasticQuery\Query\Ids(['1', '2']); + + \Tester\Assert::same('ids_1-2', $ids->key()); + } + + + public function testEmptyRejected(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\Ids([]); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void + { + $ids = new \Spameri\ElasticQuery\Query\Ids(['1', '2']); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + (new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection($ids), + ), + ))->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type('int', $result->stats()->total()); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Ids())->run(); From 0cce7f1c9bcdfa74bca33ca173163c8eee5bf306 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:39:05 +0200 Subject: [PATCH 45/97] feat(query): add prefix query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 +++ src/Query/Prefix.php | 50 +++++++++ .../ElasticQuery/Query/Prefix.phpt | 106 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/Query/Prefix.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Prefix.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index e11632a..7793425 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -163,6 +163,20 @@ Fetch documents by their `_id`. new \Spameri\ElasticQuery\Query\Ids(values: ['1', '2', '3']); ``` +##### Prefix Query +Match terms that start with a given prefix. +- Class: `\Spameri\ElasticQuery\Query\Prefix` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-prefix-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Prefix.php) + +```php +new \Spameri\ElasticQuery\Query\Prefix( + field: 'user', + query: 'ki', + caseInsensitive: true, +); +``` + ##### WildCard Query Match using wildcard patterns (* and ?). - Class: `\Spameri\ElasticQuery\Query\WildCard` diff --git a/src/Query/Prefix.php b/src/Query/Prefix.php new file mode 100644 index 0000000..2aa856d --- /dev/null +++ b/src/Query/Prefix.php @@ -0,0 +1,50 @@ +field . '_' . $this->query; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $body = [ + 'value' => $this->query, + 'boost' => $this->boost, + ]; + + if ($this->caseInsensitive !== null) { + $body['case_insensitive'] = $this->caseInsensitive; + } + + return [ + 'prefix' => [ + $this->field => $body, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Prefix.phpt b/tests/SpameriTests/ElasticQuery/Query/Prefix.phpt new file mode 100644 index 0000000..dd3e29f --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Prefix.phpt @@ -0,0 +1,106 @@ +toArray(); + + \Tester\Assert::same('ki', $array['prefix']['user']['value']); + } + + + public function testCaseInsensitive(): void + { + $prefix = new \Spameri\ElasticQuery\Query\Prefix( + field: 'user', + query: 'ki', + caseInsensitive: true, + ); + + \Tester\Assert::true($prefix->toArray()['prefix']['user']['case_insensitive']); + } + + + public function testKey(): void + { + $prefix = new \Spameri\ElasticQuery\Query\Prefix('user', 'ki'); + + \Tester\Assert::same('prefix_user_ki', $prefix->key()); + } + + + public function testCreate(): void + { + $prefix = new \Spameri\ElasticQuery\Query\Prefix('user', 'ki'); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + (new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection($prefix), + ), + ))->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type('int', $result->stats()->total()); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Prefix())->run(); From 2d3dc40ff3e8bbf29070f8da09a77cc9141b61e2 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:39:43 +0200 Subject: [PATCH 46/97] feat(query): add regexp query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 15 +++ src/Query/Regexp.php | 60 ++++++++++++ .../ElasticQuery/Query/Regexp.phpt | 94 +++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/Query/Regexp.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Regexp.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 7793425..904246d 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -177,6 +177,21 @@ new \Spameri\ElasticQuery\Query\Prefix( ); ``` +##### Regexp Query +Match terms against a regular expression (Lucene syntax). +- Class: `\Spameri\ElasticQuery\Query\Regexp` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Regexp.php) + +```php +new \Spameri\ElasticQuery\Query\Regexp( + field: 'user', + query: 'k.*y', + flags: 'ALL', + caseInsensitive: true, +); +``` + ##### WildCard Query Match using wildcard patterns (* and ?). - Class: `\Spameri\ElasticQuery\Query\WildCard` diff --git a/src/Query/Regexp.php b/src/Query/Regexp.php new file mode 100644 index 0000000..c9a1cc4 --- /dev/null +++ b/src/Query/Regexp.php @@ -0,0 +1,60 @@ +field . '_' . $this->query; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $body = [ + 'value' => $this->query, + 'boost' => $this->boost, + ]; + + if ($this->flags !== null) { + $body['flags'] = $this->flags; + } + + if ($this->caseInsensitive !== null) { + $body['case_insensitive'] = $this->caseInsensitive; + } + + if ($this->maxDeterminizedStates !== null) { + $body['max_determinized_states'] = $this->maxDeterminizedStates; + } + + return [ + 'regexp' => [ + $this->field => $body, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Regexp.phpt b/tests/SpameriTests/ElasticQuery/Query/Regexp.phpt new file mode 100644 index 0000000..1aa28e0 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Regexp.phpt @@ -0,0 +1,94 @@ +toArray(); + + \Tester\Assert::same('k.*y', $array['regexp']['user']['value']); + } + + + public function testKey(): void + { + $regexp = new \Spameri\ElasticQuery\Query\Regexp('user', 'k.*'); + + \Tester\Assert::same('regexp_user_k.*', $regexp->key()); + } + + + public function testCreate(): void + { + $regexp = new \Spameri\ElasticQuery\Query\Regexp('user', 'k.*'); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + (new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection($regexp), + ), + ))->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type('int', $result->stats()->total()); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Regexp())->run(); From 028172b6b3631d67887b37040d7cf8b644c2259b Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:40:17 +0200 Subject: [PATCH 47/97] feat(query): add term set query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 +++ src/Query/TermSet.php | 67 +++++++++++++ .../ElasticQuery/Query/TermSet.phpt | 94 +++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/Query/TermSet.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/TermSet.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 904246d..09aab8d 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -192,6 +192,20 @@ new \Spameri\ElasticQuery\Query\Regexp( ); ``` +##### TermSet Query +Match documents containing at least N of M provided terms (N defined by a field or script). +- Class: `\Spameri\ElasticQuery\Query\TermSet` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-set-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/TermSet.php) + +```php +new \Spameri\ElasticQuery\Query\TermSet( + field: 'programming_languages', + terms: ['c++', 'java', 'php'], + minimumShouldMatchField: 'required_matches', +); +``` + ##### WildCard Query Match using wildcard patterns (* and ?). - Class: `\Spameri\ElasticQuery\Query\WildCard` diff --git a/src/Query/TermSet.php b/src/Query/TermSet.php new file mode 100644 index 0000000..b52735b --- /dev/null +++ b/src/Query/TermSet.php @@ -0,0 +1,67 @@ + $terms + */ + public function __construct( + private string $field, + private array $terms, + private string|null $minimumShouldMatchField = null, + private string|null $minimumShouldMatchScript = null, + ) + { + if ($terms === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'TermSet query must contain at least one term.', + ); + } + + if ($minimumShouldMatchField === null && $minimumShouldMatchScript === null) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'TermSet query requires either minimumShouldMatchField or minimumShouldMatchScript.', + ); + } + } + + + public function key(): string + { + return 'terms_set_' . $this->field . '_' . \implode('-', $this->terms); + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $body = [ + 'terms' => $this->terms, + ]; + + if ($this->minimumShouldMatchField !== null) { + $body['minimum_should_match_field'] = $this->minimumShouldMatchField; + } + + if ($this->minimumShouldMatchScript !== null) { + $body['minimum_should_match_script'] = ['source' => $this->minimumShouldMatchScript]; + } + + return [ + 'terms_set' => [ + $this->field => $body, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/TermSet.phpt b/tests/SpameriTests/ElasticQuery/Query/TermSet.phpt new file mode 100644 index 0000000..d4ba06d --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/TermSet.phpt @@ -0,0 +1,94 @@ +toArray(); + + \Tester\Assert::same( + ['c++', 'java', 'php'], + $array['terms_set']['programming_languages']['terms'], + ); + \Tester\Assert::same( + 'required_matches', + $array['terms_set']['programming_languages']['minimum_should_match_field'], + ); + } + + + public function testRequiresTerms(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\TermSet('f', [], 'm'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testRequiresMinimumShouldMatch(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\TermSet('f', ['a']); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $termSet = new \Spameri\ElasticQuery\Query\TermSet( + 'tags', + ['php', 'es'], + minimumShouldMatchField: 'min', + ); + + \Tester\Assert::same('terms_set_tags_php-es', $termSet->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new TermSet())->run(); From 8a32256985c127bb689ecfaac122939de4559d06 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:41:05 +0200 Subject: [PATCH 48/97] feat(query): add match bool prefix query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 +++ src/Query/MatchBoolPrefix.php | 60 ++++++++++++ .../ElasticQuery/Query/MatchBoolPrefix.phpt | 97 +++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/Query/MatchBoolPrefix.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 09aab8d..0e757cc 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -88,6 +88,19 @@ new \Spameri\ElasticQuery\Query\Fuzzy( ); ``` +##### MatchBoolPrefix Query +Match where the final term is treated as a prefix and the rest as match terms. +- Class: `\Spameri\ElasticQuery\Query\MatchBoolPrefix` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-bool-prefix-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/MatchBoolPrefix.php) + +```php +new \Spameri\ElasticQuery\Query\MatchBoolPrefix( + field: 'message', + query: 'quick brown f', +); +``` + --- ## Term-level Queries diff --git a/src/Query/MatchBoolPrefix.php b/src/Query/MatchBoolPrefix.php new file mode 100644 index 0000000..7179906 --- /dev/null +++ b/src/Query/MatchBoolPrefix.php @@ -0,0 +1,60 @@ +field . '_' . $this->query; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $body = [ + 'query' => $this->query, + 'boost' => $this->boost, + ]; + + if ($this->operator !== null) { + $body['operator'] = $this->operator; + } + + if ($this->minimumShouldMatch !== null) { + $body['minimum_should_match'] = $this->minimumShouldMatch; + } + + if ($this->analyzer !== null) { + $body['analyzer'] = $this->analyzer; + } + + return [ + 'match_bool_prefix' => [ + $this->field => $body, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt b/tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt new file mode 100644 index 0000000..72e5b29 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt @@ -0,0 +1,97 @@ +toArray(); + + \Tester\Assert::same('quick brown f', $array['match_bool_prefix']['message']['query']); + } + + + public function testKey(): void + { + $match = new \Spameri\ElasticQuery\Query\MatchBoolPrefix('message', 'quick'); + + \Tester\Assert::same('match_bool_prefix_message_quick', $match->key()); + } + + + public function testCreate(): void + { + $match = new \Spameri\ElasticQuery\Query\MatchBoolPrefix('message', 'q'); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + (new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection($match), + ), + ))->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type('int', $result->stats()->total()); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MatchBoolPrefix())->run(); From a49314cea9bce73d8a5de9f80b8186eb6ec36fe2 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:41:49 +0200 Subject: [PATCH 49/97] feat(query): add combined fields query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 ++++ src/Query/CombinedFields.php | 67 +++++++++++++++++ .../ElasticQuery/Query/CombinedFields.phpt | 73 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/Query/CombinedFields.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 0e757cc..7898360 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -88,6 +88,20 @@ new \Spameri\ElasticQuery\Query\Fuzzy( ); ``` +##### CombinedFields Query +BM25-aware multi-field full-text search that treats fields as one combined field. +- Class: `\Spameri\ElasticQuery\Query\CombinedFields` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-combined-fields-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/CombinedFields.php) + +```php +new \Spameri\ElasticQuery\Query\CombinedFields( + fields: ['title', 'abstract', 'body'], + query: 'distributed search', + operator: 'and', +); +``` + ##### MatchBoolPrefix Query Match where the final term is treated as a prefix and the rest as match terms. - Class: `\Spameri\ElasticQuery\Query\MatchBoolPrefix` diff --git a/src/Query/CombinedFields.php b/src/Query/CombinedFields.php new file mode 100644 index 0000000..4de2931 --- /dev/null +++ b/src/Query/CombinedFields.php @@ -0,0 +1,67 @@ + $fields + */ + public function __construct( + private array $fields, + private string $query, + private float $boost = 1.0, + private string|null $operator = null, + private int|string|null $minimumShouldMatch = null, + private string|null $zeroTermsQuery = null, + ) + { + if ($fields === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'CombinedFields query requires at least one field.', + ); + } + } + + + public function key(): string + { + return 'combined_fields_' . \implode('-', $this->fields) . '_' . $this->query; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'query' => $this->query, + 'fields' => $this->fields, + 'boost' => $this->boost, + ]; + + if ($this->operator !== null) { + $body['operator'] = $this->operator; + } + + if ($this->minimumShouldMatch !== null) { + $body['minimum_should_match'] = $this->minimumShouldMatch; + } + + if ($this->zeroTermsQuery !== null) { + $body['zero_terms_query'] = $this->zeroTermsQuery; + } + + return [ + 'combined_fields' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt b/tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt new file mode 100644 index 0000000..9a3f3e5 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt @@ -0,0 +1,73 @@ +toArray(); + + \Tester\Assert::same(['title', 'abstract', 'body'], $array['combined_fields']['fields']); + \Tester\Assert::same('and', $array['combined_fields']['operator']); + } + + + public function testRequiresFields(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\CombinedFields([], 'x'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $cf = new \Spameri\ElasticQuery\Query\CombinedFields(['title', 'body'], 'q'); + + \Tester\Assert::same('combined_fields_title-body_q', $cf->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new CombinedFields())->run(); From c2b1d095f195a93099177b06892ae2448fe9d54f Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:42:29 +0200 Subject: [PATCH 50/97] feat(query): add query string query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/QueryString.php | 70 +++++++++++++++++++ .../ElasticQuery/Query/QueryString.phpt | 61 ++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/Query/QueryString.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/QueryString.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 7898360..520905c 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -88,6 +88,19 @@ new \Spameri\ElasticQuery\Query\Fuzzy( ); ``` +##### QueryString Query +Lucene-syntax query — supports boolean operators, wildcards, regex, fielded search. +- Class: `\Spameri\ElasticQuery\Query\QueryString` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/QueryString.php) + +```php +new \Spameri\ElasticQuery\Query\QueryString( + query: '(new york city) OR (big apple)', + defaultField: 'content', +); +``` + ##### CombinedFields Query BM25-aware multi-field full-text search that treats fields as one combined field. - Class: `\Spameri\ElasticQuery\Query\CombinedFields` diff --git a/src/Query/QueryString.php b/src/Query/QueryString.php new file mode 100644 index 0000000..b4405bb --- /dev/null +++ b/src/Query/QueryString.php @@ -0,0 +1,70 @@ + $fields + */ + public function __construct( + private string $query, + private array $fields = [], + private string|null $defaultField = null, + private string|null $defaultOperator = null, + private string|null $analyzer = null, + private bool|null $allowLeadingWildcard = null, + private float $boost = 1.0, + ) + { + } + + + public function key(): string + { + return 'query_string_' . $this->query; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'query' => $this->query, + 'boost' => $this->boost, + ]; + + if ($this->fields !== []) { + $body['fields'] = $this->fields; + } + + if ($this->defaultField !== null) { + $body['default_field'] = $this->defaultField; + } + + if ($this->defaultOperator !== null) { + $body['default_operator'] = $this->defaultOperator; + } + + if ($this->analyzer !== null) { + $body['analyzer'] = $this->analyzer; + } + + if ($this->allowLeadingWildcard !== null) { + $body['allow_leading_wildcard'] = $this->allowLeadingWildcard; + } + + return [ + 'query_string' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/QueryString.phpt b/tests/SpameriTests/ElasticQuery/Query/QueryString.phpt new file mode 100644 index 0000000..5b32c29 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/QueryString.phpt @@ -0,0 +1,61 @@ +toArray(); + + \Tester\Assert::same('(new york city) OR (big apple)', $array['query_string']['query']); + \Tester\Assert::same('content', $array['query_string']['default_field']); + } + + + public function testKey(): void + { + $qs = new \Spameri\ElasticQuery\Query\QueryString('foo'); + + \Tester\Assert::same('query_string_foo', $qs->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new QueryString())->run(); From 02c8c60688868a27a2e242b2a21a946dbb1ef57f Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:43:11 +0200 Subject: [PATCH 51/97] feat(query): add simple query string query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/SimpleQueryString.php | 65 +++++++++++++++++++ .../ElasticQuery/Query/SimpleQueryString.phpt | 61 +++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 src/Query/SimpleQueryString.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 520905c..3e94b6c 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -88,6 +88,19 @@ new \Spameri\ElasticQuery\Query\Fuzzy( ); ``` +##### SimpleQueryString Query +User-safe Lucene-lite syntax (does not error on invalid input). +- Class: `\Spameri\ElasticQuery\Query\SimpleQueryString` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SimpleQueryString.php) + +```php +new \Spameri\ElasticQuery\Query\SimpleQueryString( + query: 'foo + bar -baz', + fields: ['title^2', 'body'], +); +``` + ##### QueryString Query Lucene-syntax query — supports boolean operators, wildcards, regex, fielded search. - Class: `\Spameri\ElasticQuery\Query\QueryString` diff --git a/src/Query/SimpleQueryString.php b/src/Query/SimpleQueryString.php new file mode 100644 index 0000000..5d54748 --- /dev/null +++ b/src/Query/SimpleQueryString.php @@ -0,0 +1,65 @@ + $fields + */ + public function __construct( + private string $query, + private array $fields = [], + private string|null $defaultOperator = null, + private string|null $analyzer = null, + private string|null $flags = null, + private float $boost = 1.0, + ) + { + } + + + public function key(): string + { + return 'simple_query_string_' . $this->query; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'query' => $this->query, + 'boost' => $this->boost, + ]; + + if ($this->fields !== []) { + $body['fields'] = $this->fields; + } + + if ($this->defaultOperator !== null) { + $body['default_operator'] = $this->defaultOperator; + } + + if ($this->analyzer !== null) { + $body['analyzer'] = $this->analyzer; + } + + if ($this->flags !== null) { + $body['flags'] = $this->flags; + } + + return [ + 'simple_query_string' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt b/tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt new file mode 100644 index 0000000..154a0a0 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt @@ -0,0 +1,61 @@ +toArray(); + + \Tester\Assert::same('foo + bar -baz', $array['simple_query_string']['query']); + \Tester\Assert::same(['title^2', 'body'], $array['simple_query_string']['fields']); + } + + + public function testKey(): void + { + $sqs = new \Spameri\ElasticQuery\Query\SimpleQueryString('q'); + + \Tester\Assert::same('simple_query_string_q', $sqs->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SimpleQueryString())->run(); From 4bfb9f92389317aede65ee06506c8c93dd050610 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:43:51 +0200 Subject: [PATCH 52/97] feat(query): add intervals query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 19 +++++ src/Query/Intervals.php | 48 ++++++++++++ .../ElasticQuery/Query/Intervals.phpt | 77 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/Query/Intervals.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Intervals.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 3e94b6c..b1515c7 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -88,6 +88,25 @@ new \Spameri\ElasticQuery\Query\Fuzzy( ); ``` +##### Intervals Query +Rule-based proximity / ordered-term matching (match, prefix, wildcard, fuzzy, all_of, any_of, with `max_gaps`, `ordered`, filters). +- Class: `\Spameri\ElasticQuery\Query\Intervals` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-intervals-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Intervals.php) + +```php +new \Spameri\ElasticQuery\Query\Intervals( + field: 'my_text', + rule: [ + 'match' => [ + 'query' => 'my favorite food', + 'max_gaps' => 0, + 'ordered' => true, + ], + ], +); +``` + ##### SimpleQueryString Query User-safe Lucene-lite syntax (does not error on invalid input). - Class: `\Spameri\ElasticQuery\Query\SimpleQueryString` diff --git a/src/Query/Intervals.php b/src/Query/Intervals.php new file mode 100644 index 0000000..c378d33 --- /dev/null +++ b/src/Query/Intervals.php @@ -0,0 +1,48 @@ + $rule e.g. ['match' => ['query' => 'my favorite food', 'max_gaps' => 0]] + * or ['all_of' => ['intervals' => [...], 'max_gaps' => 0]]. + */ + public function __construct( + private string $field, + private array $rule, + ) + { + if ($rule === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Intervals query requires at least one rule.', + ); + } + } + + + public function key(): string + { + return 'intervals_' . $this->field; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + return [ + 'intervals' => [ + $this->field => $this->rule, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Intervals.phpt b/tests/SpameriTests/ElasticQuery/Query/Intervals.phpt new file mode 100644 index 0000000..63aeced --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Intervals.phpt @@ -0,0 +1,77 @@ + [ + 'query' => 'my favorite food', + 'max_gaps' => 0, + 'ordered' => true, + ], + ], + ); + + $array = $intervals->toArray(); + + \Tester\Assert::same('my favorite food', $array['intervals']['my_text']['match']['query']); + } + + + public function testRequiresRule(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\Intervals('f', []); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $intervals = new \Spameri\ElasticQuery\Query\Intervals('f', ['match' => ['query' => 'x']]); + + \Tester\Assert::same('intervals_f', $intervals->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Intervals())->run(); From 573b19f5da7f725d699a4dadd093279908ca88c2 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:44:39 +0200 Subject: [PATCH 53/97] feat(query): add match none query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 10 ++ src/Query/MatchNone.php | 29 ++++++ .../ElasticQuery/Query/MatchNone.phpt | 93 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 src/Query/MatchNone.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/MatchNone.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index b1515c7..033cfd4 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -309,6 +309,16 @@ new \Spameri\ElasticQuery\Query\MatchAll(); new \Spameri\ElasticQuery\Query\MatchAll(boost: 1.5); ``` +##### MatchNone Query +Matches zero documents — useful as a placeholder. +- Class: `\Spameri\ElasticQuery\Query\MatchNone` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-all-query.html#query-dsl-match-none-query) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/MatchNone.php) + +```php +new \Spameri\ElasticQuery\Query\MatchNone(); +``` + ##### Nested Query Query nested objects with their own scope. - Class: `\Spameri\ElasticQuery\Query\Nested` diff --git a/src/Query/MatchNone.php b/src/Query/MatchNone.php new file mode 100644 index 0000000..853de87 --- /dev/null +++ b/src/Query/MatchNone.php @@ -0,0 +1,29 @@ + + */ + public function toArray(): array + { + return [ + 'match_none' => new \stdClass(), + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/MatchNone.phpt b/tests/SpameriTests/ElasticQuery/Query/MatchNone.phpt new file mode 100644 index 0000000..a0737c6 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/MatchNone.phpt @@ -0,0 +1,93 @@ +toArray(); + + \Tester\Assert::true(isset($array['match_none'])); + \Tester\Assert::type(\stdClass::class, $array['match_none']); + } + + + public function testKey(): void + { + \Tester\Assert::same('match_none', (new \Spameri\ElasticQuery\Query\MatchNone())->key()); + } + + + public function testCreate(): void + { + $matchNone = new \Spameri\ElasticQuery\Query\MatchNone(); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + (new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection($matchNone), + ), + ))->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt( + $ch, + \CURLOPT_POSTFIELDS, + \json_encode($document->toArray()['body']), + ); + + \Tester\Assert::noError(static function () use ($ch): void { + $response = \curl_exec($ch); + $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); + /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ + $result = $resultMapper->map(\json_decode($response, true)); + \Tester\Assert::type('int', $result->stats()->total()); + }); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MatchNone())->run(); From f3c431314dba8ba9b20553dedf83dec1842c4bf0 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:45:15 +0200 Subject: [PATCH 54/97] feat(query): add boosting compound query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 20 ++++++ src/Query/Boosting.php | 42 ++++++++++++ .../ElasticQuery/Query/Boosting.phpt | 67 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 src/Query/Boosting.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Boosting.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 033cfd4..f0de62d 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -293,6 +293,26 @@ new \Spameri\ElasticQuery\Query\WildCard( --- +## Compound Queries + +Wrap other queries to combine, filter, or modify their scoring. + +##### Boosting Query +Match `positive` docs but lower the score of `negative` matches. +- Class: `\Spameri\ElasticQuery\Query\Boosting` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-boosting-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Boosting.php) + +```php +new \Spameri\ElasticQuery\Query\Boosting( + positive: new \Spameri\ElasticQuery\Query\Term('text', 'apple'), + negative: new \Spameri\ElasticQuery\Query\Term('text', 'pie'), + negativeBoost: 0.5, +); +``` + +--- + ## Specialized Queries ##### MatchAll Query diff --git a/src/Query/Boosting.php b/src/Query/Boosting.php new file mode 100644 index 0000000..8fd317f --- /dev/null +++ b/src/Query/Boosting.php @@ -0,0 +1,42 @@ +positive->key() . '_' . $this->negative->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'boosting' => [ + 'positive' => $this->positive->toArray(), + 'negative' => $this->negative->toArray(), + 'negative_boost' => $this->negativeBoost, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Boosting.phpt b/tests/SpameriTests/ElasticQuery/Query/Boosting.phpt new file mode 100644 index 0000000..510ff24 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Boosting.phpt @@ -0,0 +1,67 @@ +toArray(); + + \Tester\Assert::same(0.5, $array['boosting']['negative_boost']); + \Tester\Assert::same('apple', $array['boosting']['positive']['term']['text']['value']); + \Tester\Assert::same('pie', $array['boosting']['negative']['term']['text']['value']); + } + + + public function testKey(): void + { + $boosting = new \Spameri\ElasticQuery\Query\Boosting( + new \Spameri\ElasticQuery\Query\Term('text', 'a'), + new \Spameri\ElasticQuery\Query\Term('text', 'b'), + 0.5, + ); + + \Tester\Assert::same('boosting_term_text_a_term_text_b', $boosting->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Boosting())->run(); From 2bcb7fb80065d7ea94da1b1eff4e83bfb36f68e3 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:45:47 +0200 Subject: [PATCH 55/97] feat(query): add constant score compound query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/ConstantScore.php | 40 ++++++++++++ .../ElasticQuery/Query/ConstantScore.phpt | 63 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/Query/ConstantScore.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/ConstantScore.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index f0de62d..1e30575 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -297,6 +297,19 @@ new \Spameri\ElasticQuery\Query\WildCard( Wrap other queries to combine, filter, or modify their scoring. +##### ConstantScore Query +Wraps a filter so all matching docs share the same score. +- Class: `\Spameri\ElasticQuery\Query\ConstantScore` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-constant-score-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/ConstantScore.php) + +```php +new \Spameri\ElasticQuery\Query\ConstantScore( + filter: new \Spameri\ElasticQuery\Query\Term('status', 'active'), + boost: 1.2, +); +``` + ##### Boosting Query Match `positive` docs but lower the score of `negative` matches. - Class: `\Spameri\ElasticQuery\Query\Boosting` diff --git a/src/Query/ConstantScore.php b/src/Query/ConstantScore.php new file mode 100644 index 0000000..5171f21 --- /dev/null +++ b/src/Query/ConstantScore.php @@ -0,0 +1,40 @@ +filter->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'constant_score' => [ + 'filter' => $this->filter->toArray(), + 'boost' => $this->boost, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/ConstantScore.phpt b/tests/SpameriTests/ElasticQuery/Query/ConstantScore.phpt new file mode 100644 index 0000000..8d965cf --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/ConstantScore.phpt @@ -0,0 +1,63 @@ +toArray(); + + \Tester\Assert::same(1.2, $array['constant_score']['boost']); + \Tester\Assert::same('active', $array['constant_score']['filter']['term']['status']['value']); + } + + + public function testKey(): void + { + $cs = new \Spameri\ElasticQuery\Query\ConstantScore( + new \Spameri\ElasticQuery\Query\Term('status', 'active'), + ); + + \Tester\Assert::same('constant_score_term_status_active', $cs->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new ConstantScore())->run(); From 8827fa3eaf9fbc9a6655e5974376f650a8379800 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:46:20 +0200 Subject: [PATCH 56/97] feat(query): add dis max compound query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 ++++ src/Query/DisMax.php | 65 +++++++++++++++++++ .../ElasticQuery/Query/DisMax.phpt | 64 ++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 src/Query/DisMax.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/DisMax.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 1e30575..fccaacc 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -310,6 +310,20 @@ new \Spameri\ElasticQuery\Query\ConstantScore( ); ``` +##### DisMax Query +Best-of-many — returns the highest-scoring sub-query per document, with `tie_breaker` for the rest. +- Class: `\Spameri\ElasticQuery\Query\DisMax` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-dis-max-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/DisMax.php) + +```php +$disMax = new \Spameri\ElasticQuery\Query\DisMax( + query: new \Spameri\ElasticQuery\Query\Term('title', 'foo'), + tieBreaker: 0.7, +); +$disMax->addQuery(new \Spameri\ElasticQuery\Query\Term('body', 'foo')); +``` + ##### Boosting Query Match `positive` docs but lower the score of `negative` matches. - Class: `\Spameri\ElasticQuery\Query\Boosting` diff --git a/src/Query/DisMax.php b/src/Query/DisMax.php new file mode 100644 index 0000000..bd7a842 --- /dev/null +++ b/src/Query/DisMax.php @@ -0,0 +1,65 @@ + + */ + private array $queries; + + + public function __construct( + \Spameri\ElasticQuery\Query\LeafQueryInterface $query, + private float $tieBreaker = 0.0, + private float $boost = 1.0, + ) + { + $this->queries = [$query]; + } + + + public function addQuery(\Spameri\ElasticQuery\Query\LeafQueryInterface $query): void + { + $this->queries[] = $query; + } + + + public function key(): string + { + $keys = []; + foreach ($this->queries as $query) { + $keys[] = $query->key(); + } + + return 'dis_max_' . \implode('-', $keys); + } + + + /** + * @return array> + */ + public function toArray(): array + { + $queries = []; + foreach ($this->queries as $query) { + $queries[] = $query->toArray(); + } + + return [ + 'dis_max' => [ + 'queries' => $queries, + 'tie_breaker' => $this->tieBreaker, + 'boost' => $this->boost, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/DisMax.phpt b/tests/SpameriTests/ElasticQuery/Query/DisMax.phpt new file mode 100644 index 0000000..dd6c3f7 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/DisMax.phpt @@ -0,0 +1,64 @@ +addQuery(new \Spameri\ElasticQuery\Query\Term('body', 'foo')); + + $array = $disMax->toArray(); + + \Tester\Assert::same(0.7, $array['dis_max']['tie_breaker']); + \Tester\Assert::count(2, $array['dis_max']['queries']); + } + + + public function testKey(): void + { + $disMax = new \Spameri\ElasticQuery\Query\DisMax( + new \Spameri\ElasticQuery\Query\Term('title', 'foo'), + ); + + \Tester\Assert::same('dis_max_term_title_foo', $disMax->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new DisMax())->run(); From 40b57ac7920ddd47ec2b66bd2918860871387b5c Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:46:56 +0200 Subject: [PATCH 57/97] feat(query): add has child joining query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 21 ++++++ src/Query/HasChild.php | 62 +++++++++++++++++ .../ElasticQuery/Query/HasChild.phpt | 67 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/Query/HasChild.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/HasChild.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index fccaacc..cb74c49 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -340,6 +340,27 @@ new \Spameri\ElasticQuery\Query\Boosting( --- +## Joining Queries + +Queries that traverse parent/child or join relationships. + +##### HasChild Query +Match parents whose children match the inner query. +- Class: `\Spameri\ElasticQuery\Query\HasChild` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-has-child-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/HasChild.php) + +```php +new \Spameri\ElasticQuery\Query\HasChild( + type: 'comment', + query: new \Spameri\ElasticQuery\Query\Term('author', 'john'), + scoreMode: 'max', + minChildren: 1, +); +``` + +--- + ## Specialized Queries ##### MatchAll Query diff --git a/src/Query/HasChild.php b/src/Query/HasChild.php new file mode 100644 index 0000000..3076594 --- /dev/null +++ b/src/Query/HasChild.php @@ -0,0 +1,62 @@ +type; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'type' => $this->type, + 'query' => $this->query->toArray(), + ]; + + if ($this->scoreMode !== null) { + $body['score_mode'] = $this->scoreMode; + } + + if ($this->minChildren !== null) { + $body['min_children'] = $this->minChildren; + } + + if ($this->maxChildren !== null) { + $body['max_children'] = $this->maxChildren; + } + + if ($this->ignoreUnmapped !== null) { + $body['ignore_unmapped'] = $this->ignoreUnmapped; + } + + return [ + 'has_child' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/HasChild.phpt b/tests/SpameriTests/ElasticQuery/Query/HasChild.phpt new file mode 100644 index 0000000..b745144 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/HasChild.phpt @@ -0,0 +1,67 @@ +toArray(); + + \Tester\Assert::same('comment', $array['has_child']['type']); + \Tester\Assert::same('max', $array['has_child']['score_mode']); + \Tester\Assert::same(1, $array['has_child']['min_children']); + } + + + public function testKey(): void + { + $hasChild = new \Spameri\ElasticQuery\Query\HasChild( + 'comment', + new \Spameri\ElasticQuery\Query\Term('author', 'john'), + ); + + \Tester\Assert::same('has_child_comment', $hasChild->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new HasChild())->run(); From 0369e28a90166e4fc3aca4a481900b2cb67e772e Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:47:31 +0200 Subject: [PATCH 58/97] feat(query): add has parent joining query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 ++++ src/Query/HasParent.php | 52 +++++++++++++++ .../ElasticQuery/Query/HasParent.phpt | 65 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/Query/HasParent.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/HasParent.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index cb74c49..160019f 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -344,6 +344,20 @@ new \Spameri\ElasticQuery\Query\Boosting( Queries that traverse parent/child or join relationships. +##### HasParent Query +Match children whose parent matches the inner query. +- Class: `\Spameri\ElasticQuery\Query\HasParent` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-has-parent-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/HasParent.php) + +```php +new \Spameri\ElasticQuery\Query\HasParent( + parentType: 'blog', + query: new \Spameri\ElasticQuery\Query\Term('tag', 'tech'), + score: true, +); +``` + ##### HasChild Query Match parents whose children match the inner query. - Class: `\Spameri\ElasticQuery\Query\HasChild` diff --git a/src/Query/HasParent.php b/src/Query/HasParent.php new file mode 100644 index 0000000..05eaab2 --- /dev/null +++ b/src/Query/HasParent.php @@ -0,0 +1,52 @@ +parentType; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'parent_type' => $this->parentType, + 'query' => $this->query->toArray(), + ]; + + if ($this->score !== null) { + $body['score'] = $this->score; + } + + if ($this->ignoreUnmapped !== null) { + $body['ignore_unmapped'] = $this->ignoreUnmapped; + } + + return [ + 'has_parent' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/HasParent.phpt b/tests/SpameriTests/ElasticQuery/Query/HasParent.phpt new file mode 100644 index 0000000..ef06df9 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/HasParent.phpt @@ -0,0 +1,65 @@ +toArray(); + + \Tester\Assert::same('blog', $array['has_parent']['parent_type']); + \Tester\Assert::true($array['has_parent']['score']); + } + + + public function testKey(): void + { + $hasParent = new \Spameri\ElasticQuery\Query\HasParent( + 'blog', + new \Spameri\ElasticQuery\Query\Term('tag', 'tech'), + ); + + \Tester\Assert::same('has_parent_blog', $hasParent->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new HasParent())->run(); From 94266a7501ee4d9335aa8a54f7d53c53c71feb0f Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:48:01 +0200 Subject: [PATCH 59/97] feat(query): add parent id joining query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 10 ++++ src/Query/ParentId.php | 47 +++++++++++++++ .../ElasticQuery/Query/ParentId.phpt | 58 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 src/Query/ParentId.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/ParentId.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 160019f..6a931e3 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -344,6 +344,16 @@ new \Spameri\ElasticQuery\Query\Boosting( Queries that traverse parent/child or join relationships. +##### ParentId Query +Match children that belong to a parent with a known id. +- Class: `\Spameri\ElasticQuery\Query\ParentId` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-parent-id-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/ParentId.php) + +```php +new \Spameri\ElasticQuery\Query\ParentId(type: 'comment', id: '1'); +``` + ##### HasParent Query Match children whose parent matches the inner query. - Class: `\Spameri\ElasticQuery\Query\HasParent` diff --git a/src/Query/ParentId.php b/src/Query/ParentId.php new file mode 100644 index 0000000..4a8a004 --- /dev/null +++ b/src/Query/ParentId.php @@ -0,0 +1,47 @@ +type . '_' . $this->id; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'type' => $this->type, + 'id' => $this->id, + ]; + + if ($this->ignoreUnmapped !== null) { + $body['ignore_unmapped'] = $this->ignoreUnmapped; + } + + return [ + 'parent_id' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/ParentId.phpt b/tests/SpameriTests/ElasticQuery/Query/ParentId.phpt new file mode 100644 index 0000000..3907956 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/ParentId.phpt @@ -0,0 +1,58 @@ +toArray(); + + \Tester\Assert::same('comment', $array['parent_id']['type']); + \Tester\Assert::same('1', $array['parent_id']['id']); + } + + + public function testKey(): void + { + $parentId = new \Spameri\ElasticQuery\Query\ParentId('comment', '1'); + + \Tester\Assert::same('parent_id_comment_1', $parentId->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new ParentId())->run(); From 93bcfe1ef9cdfece49d2a30cdac63214e6e541ac Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:48:52 +0200 Subject: [PATCH 60/97] feat(query): add geo bounding box query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 16 +++++ src/Query/GeoBoundingBox.php | 58 +++++++++++++++++ .../ElasticQuery/Query/GeoBoundingBox.phpt | 64 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 src/Query/GeoBoundingBox.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 6a931e3..b4a11d4 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -441,6 +441,22 @@ new \Spameri\ElasticQuery\Query\GeoDistance( ); ``` +##### GeoBoundingBox Query +Match documents whose geo_point falls inside a top-left/bottom-right rectangle. +- Class: `\Spameri\ElasticQuery\Query\GeoBoundingBox` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-bounding-box-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/GeoBoundingBox.php) + +```php +new \Spameri\ElasticQuery\Query\GeoBoundingBox( + field: 'location', + topLeftLat: 40.73, + topLeftLon: -74.1, + bottomRightLat: 40.01, + bottomRightLon: -71.12, +); +``` + --- ## Boolean Query Collections diff --git a/src/Query/GeoBoundingBox.php b/src/Query/GeoBoundingBox.php new file mode 100644 index 0000000..72a8690 --- /dev/null +++ b/src/Query/GeoBoundingBox.php @@ -0,0 +1,58 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + $this->field => [ + 'top_left' => [ + 'lat' => $this->topLeftLat, + 'lon' => $this->topLeftLon, + ], + 'bottom_right' => [ + 'lat' => $this->bottomRightLat, + 'lon' => $this->bottomRightLon, + ], + ], + ]; + + if ($this->type !== null) { + $body['type'] = $this->type; + } + + return [ + 'geo_bounding_box' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt b/tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt new file mode 100644 index 0000000..19f76c4 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt @@ -0,0 +1,64 @@ +toArray(); + + \Tester\Assert::same(40.73, $array['geo_bounding_box']['location']['top_left']['lat']); + \Tester\Assert::same(-71.12, $array['geo_bounding_box']['location']['bottom_right']['lon']); + } + + + public function testKey(): void + { + $gbb = new \Spameri\ElasticQuery\Query\GeoBoundingBox('location', 1, 1, 0, 0); + + \Tester\Assert::same('geo_bounding_box_location', $gbb->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GeoBoundingBox())->run(); From ead03cda76fbab3e93ab257eaf4fe0e175f3c0af Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:49:29 +0200 Subject: [PATCH 61/97] feat(query): add geo shape query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 17 +++++ src/Query/GeoShape.php | 59 ++++++++++++++ .../ElasticQuery/Query/GeoShape.phpt | 76 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/Query/GeoShape.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index b4a11d4..c54b77e 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -441,6 +441,23 @@ new \Spameri\ElasticQuery\Query\GeoDistance( ); ``` +##### GeoShape Query +Match `geo_shape`-indexed documents against an arbitrary geometry. +- Class: `\Spameri\ElasticQuery\Query\GeoShape` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-shape-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/GeoShape.php) + +```php +new \Spameri\ElasticQuery\Query\GeoShape( + field: 'location', + shape: [ + 'type' => 'envelope', + 'coordinates' => [[13.0, 53.0], [14.0, 52.0]], + ], + relation: 'within', // intersects | disjoint | within | contains +); +``` + ##### GeoBoundingBox Query Match documents whose geo_point falls inside a top-left/bottom-right rectangle. - Class: `\Spameri\ElasticQuery\Query\GeoBoundingBox` diff --git a/src/Query/GeoShape.php b/src/Query/GeoShape.php new file mode 100644 index 0000000..bc0de49 --- /dev/null +++ b/src/Query/GeoShape.php @@ -0,0 +1,59 @@ + $shape GeoJSON-style shape, e.g. + * ['type' => 'envelope', 'coordinates' => [[13, 53], [14, 52]]]. + */ + public function __construct( + private string $field, + private array $shape, + private string $relation = 'intersects', + private bool|null $ignoreUnmapped = null, + ) + { + if ( ! \in_array($relation, ['intersects', 'disjoint', 'within', 'contains'], true)) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'GeoShape relation must be one of: intersects, disjoint, within, contains.', + ); + } + } + + + public function key(): string + { + return 'geo_shape_' . $this->field; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $inner = [ + 'shape' => $this->shape, + 'relation' => $this->relation, + ]; + + if ($this->ignoreUnmapped !== null) { + $inner['ignore_unmapped'] = $this->ignoreUnmapped; + } + + return [ + 'geo_shape' => [ + $this->field => $inner, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt b/tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt new file mode 100644 index 0000000..8546ceb --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt @@ -0,0 +1,76 @@ + 'envelope', + 'coordinates' => [[13.0, 53.0], [14.0, 52.0]], + ], + relation: 'within', + ); + + $array = $geoShape->toArray(); + + \Tester\Assert::same('envelope', $array['geo_shape']['location']['shape']['type']); + \Tester\Assert::same('within', $array['geo_shape']['location']['relation']); + } + + + public function testRelationValidated(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\GeoShape('loc', ['type' => 'point', 'coordinates' => [0, 0]], 'nonsense'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $geoShape = new \Spameri\ElasticQuery\Query\GeoShape('location', ['type' => 'point', 'coordinates' => [0, 0]]); + + \Tester\Assert::same('geo_shape_location', $geoShape->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new GeoShape())->run(); From 8e4d7d867119c4359f2f5ada2caa4b482b179d50 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:50:01 +0200 Subject: [PATCH 62/97] feat(query): add shape query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 +++++ src/Query/Shape.php | 58 ++++++++++++++++++ .../ElasticQuery/Query/Shape.phpt | 61 +++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 src/Query/Shape.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Shape.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index c54b77e..18288ec 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -441,6 +441,20 @@ new \Spameri\ElasticQuery\Query\GeoDistance( ); ``` +##### Shape Query +Same as `geo_shape` but for Cartesian `shape` fields (non-geographic plane). +- Class: `\Spameri\ElasticQuery\Query\Shape` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-shape-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Shape.php) + +```php +new \Spameri\ElasticQuery\Query\Shape( + field: 'geometry', + shape: ['type' => 'envelope', 'coordinates' => [[0, 100], [100, 0]]], + relation: 'intersects', +); +``` + ##### GeoShape Query Match `geo_shape`-indexed documents against an arbitrary geometry. - Class: `\Spameri\ElasticQuery\Query\GeoShape` diff --git a/src/Query/Shape.php b/src/Query/Shape.php new file mode 100644 index 0000000..a965f6d --- /dev/null +++ b/src/Query/Shape.php @@ -0,0 +1,58 @@ + $shape + */ + public function __construct( + private string $field, + private array $shape, + private string $relation = 'intersects', + private bool|null $ignoreUnmapped = null, + ) + { + if ( ! \in_array($relation, ['intersects', 'disjoint', 'within', 'contains'], true)) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Shape relation must be one of: intersects, disjoint, within, contains.', + ); + } + } + + + public function key(): string + { + return 'shape_' . $this->field; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $inner = [ + 'shape' => $this->shape, + 'relation' => $this->relation, + ]; + + if ($this->ignoreUnmapped !== null) { + $inner['ignore_unmapped'] = $this->ignoreUnmapped; + } + + return [ + 'shape' => [ + $this->field => $inner, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Shape.phpt b/tests/SpameriTests/ElasticQuery/Query/Shape.phpt new file mode 100644 index 0000000..8303185 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Shape.phpt @@ -0,0 +1,61 @@ + 'envelope', 'coordinates' => [[0, 100], [100, 0]]], + relation: 'intersects', + ); + + $array = $shape->toArray(); + + \Tester\Assert::same('envelope', $array['shape']['geometry']['shape']['type']); + } + + + public function testKey(): void + { + $shape = new \Spameri\ElasticQuery\Query\Shape('geometry', ['type' => 'point', 'coordinates' => [0, 0]]); + + \Tester\Assert::same('shape_geometry', $shape->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Shape())->run(); From 96e9126da8b83c33c02ef62441923b42098958af Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:50:29 +0200 Subject: [PATCH 63/97] feat(query): add script query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 15 +++++ src/Query/Script.php | 52 +++++++++++++++ .../ElasticQuery/Query/Script.phpt | 64 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/Query/Script.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Script.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 18288ec..b592b45 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -490,6 +490,21 @@ new \Spameri\ElasticQuery\Query\GeoBoundingBox( --- +##### Script Query +Filter documents with a Painless boolean script. +- Class: `\Spameri\ElasticQuery\Query\Script` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Script.php) + +```php +new \Spameri\ElasticQuery\Query\Script( + source: "doc['amount'].value > params.threshold", + params: ['threshold' => 100], +); +``` + +--- + ## Boolean Query Collections These collections implement `LeafQueryInterface` and can be nested arbitrarily. diff --git a/src/Query/Script.php b/src/Query/Script.php new file mode 100644 index 0000000..91653e6 --- /dev/null +++ b/src/Query/Script.php @@ -0,0 +1,52 @@ + $params + */ + public function __construct( + private string $source, + private string $lang = 'painless', + private array $params = [], + ) + { + } + + + public function key(): string + { + return 'script_' . \md5($this->source); + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $script = [ + 'source' => $this->source, + 'lang' => $this->lang, + ]; + + if ($this->params !== []) { + $script['params'] = $this->params; + } + + return [ + 'script' => [ + 'script' => $script, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Script.phpt b/tests/SpameriTests/ElasticQuery/Query/Script.phpt new file mode 100644 index 0000000..f5ad52a --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Script.phpt @@ -0,0 +1,64 @@ + params.threshold", + params: ['threshold' => 100], + ); + + $array = $script->toArray(); + + \Tester\Assert::same( + "doc['amount'].value > params.threshold", + $array['script']['script']['source'], + ); + \Tester\Assert::same(['threshold' => 100], $array['script']['script']['params']); + } + + + public function testKey(): void + { + $script = new \Spameri\ElasticQuery\Query\Script('1 == 1'); + + \Tester\Assert::contains('script_', $script->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Script())->run(); From d64cf175fac3f09770f713ab8672a76fef23a290 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:51:04 +0200 Subject: [PATCH 64/97] feat(query): add script score query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 ++++ src/Query/ScriptScore.php | 63 ++++++++++++++++++ .../ElasticQuery/Query/ScriptScore.phpt | 65 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 src/Query/ScriptScore.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/ScriptScore.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index b592b45..47af485 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -490,6 +490,20 @@ new \Spameri\ElasticQuery\Query\GeoBoundingBox( --- +##### ScriptScore Query +Re-score matching documents using a Painless script. +- Class: `\Spameri\ElasticQuery\Query\ScriptScore` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-script-score-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/ScriptScore.php) + +```php +new \Spameri\ElasticQuery\Query\ScriptScore( + query: new \Spameri\ElasticQuery\Query\MatchAll(), + source: "doc['my_field'].value * 2", + minScore: 0.5, +); +``` + ##### Script Query Filter documents with a Painless boolean script. - Class: `\Spameri\ElasticQuery\Query\Script` diff --git a/src/Query/ScriptScore.php b/src/Query/ScriptScore.php new file mode 100644 index 0000000..025ceda --- /dev/null +++ b/src/Query/ScriptScore.php @@ -0,0 +1,63 @@ + $params + */ + public function __construct( + private \Spameri\ElasticQuery\Query\LeafQueryInterface $query, + private string $source, + private string $lang = 'painless', + private array $params = [], + private float|null $minScore = null, + private float $boost = 1.0, + ) + { + } + + + public function key(): string + { + return 'script_score_' . $this->query->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + $script = [ + 'source' => $this->source, + 'lang' => $this->lang, + ]; + + if ($this->params !== []) { + $script['params'] = $this->params; + } + + $body = [ + 'query' => $this->query->toArray(), + 'script' => $script, + 'boost' => $this->boost, + ]; + + if ($this->minScore !== null) { + $body['min_score'] = $this->minScore; + } + + return [ + 'script_score' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/ScriptScore.phpt b/tests/SpameriTests/ElasticQuery/Query/ScriptScore.phpt new file mode 100644 index 0000000..1834857 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/ScriptScore.phpt @@ -0,0 +1,65 @@ +toArray(); + + \Tester\Assert::same("doc['my_field'].value * 2", $array['script_score']['script']['source']); + \Tester\Assert::same(0.5, $array['script_score']['min_score']); + } + + + public function testKey(): void + { + $ss = new \Spameri\ElasticQuery\Query\ScriptScore( + query: new \Spameri\ElasticQuery\Query\MatchAll(), + source: '_score', + ); + + \Tester\Assert::same('script_score_match_all', $ss->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new ScriptScore())->run(); From 6e24431146a1aec1f71870c9fc37f57f09397608 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:51:41 +0200 Subject: [PATCH 65/97] feat(query): add more like this query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 15 ++++ src/Query/MoreLikeThis.php | 78 +++++++++++++++++ .../ElasticQuery/Query/MoreLikeThis.phpt | 85 +++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/Query/MoreLikeThis.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 47af485..a0ff4a2 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -504,6 +504,21 @@ new \Spameri\ElasticQuery\Query\ScriptScore( ); ``` +##### MoreLikeThis Query +Find documents similar to provided text or document references. +- Class: `\Spameri\ElasticQuery\Query\MoreLikeThis` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-mlt-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/MoreLikeThis.php) + +```php +new \Spameri\ElasticQuery\Query\MoreLikeThis( + fields: ['title', 'body'], + like: ['quick brown fox', ['_index' => 'imdb', '_id' => '1']], + minTermFreq: 1, + maxQueryTerms: 12, +); +``` + ##### Script Query Filter documents with a Painless boolean script. - Class: `\Spameri\ElasticQuery\Query\Script` diff --git a/src/Query/MoreLikeThis.php b/src/Query/MoreLikeThis.php new file mode 100644 index 0000000..c5839c1 --- /dev/null +++ b/src/Query/MoreLikeThis.php @@ -0,0 +1,78 @@ + $fields + * @param array> $like Texts or doc refs (['_index' => ..., '_id' => ...]). + * @param array> $unlike + */ + public function __construct( + private array $fields, + private array $like, + private array $unlike = [], + private int|null $minTermFreq = null, + private int|null $maxQueryTerms = null, + private int|string|null $minimumShouldMatch = null, + ) + { + if ($fields === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'MoreLikeThis query requires at least one field.', + ); + } + + if ($like === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'MoreLikeThis query requires at least one like value.', + ); + } + } + + + public function key(): string + { + return 'more_like_this_' . \implode('-', $this->fields); + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'fields' => $this->fields, + 'like' => $this->like, + ]; + + if ($this->unlike !== []) { + $body['unlike'] = $this->unlike; + } + + if ($this->minTermFreq !== null) { + $body['min_term_freq'] = $this->minTermFreq; + } + + if ($this->maxQueryTerms !== null) { + $body['max_query_terms'] = $this->maxQueryTerms; + } + + if ($this->minimumShouldMatch !== null) { + $body['minimum_should_match'] = $this->minimumShouldMatch; + } + + return [ + 'more_like_this' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt b/tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt new file mode 100644 index 0000000..f76ee63 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt @@ -0,0 +1,85 @@ + 'imdb', '_id' => '1']], + minTermFreq: 1, + maxQueryTerms: 12, + ); + + $array = $mlt->toArray(); + + \Tester\Assert::same(['title', 'body'], $array['more_like_this']['fields']); + \Tester\Assert::same(1, $array['more_like_this']['min_term_freq']); + } + + + public function testRequiresFields(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\MoreLikeThis([], ['foo']); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testRequiresLike(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\MoreLikeThis(['f'], []); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $mlt = new \Spameri\ElasticQuery\Query\MoreLikeThis(['title'], ['foo']); + + \Tester\Assert::same('more_like_this_title', $mlt->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new MoreLikeThis())->run(); From 8b42e84473b0a115edeaaf66e8490409d870e2ae Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:52:14 +0200 Subject: [PATCH 66/97] feat(query): add rank feature query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/RankFeature.php | 54 ++++++++++++++++ .../ElasticQuery/Query/RankFeature.phpt | 61 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/Query/RankFeature.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/RankFeature.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index a0ff4a2..3bc01ee 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -519,6 +519,19 @@ new \Spameri\ElasticQuery\Query\MoreLikeThis( ); ``` +##### RankFeature Query +Boost documents by a `rank_feature` field (saturation/log/sigmoid/linear functions). +- Class: `\Spameri\ElasticQuery\Query\RankFeature` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-rank-feature-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/RankFeature.php) + +```php +new \Spameri\ElasticQuery\Query\RankFeature( + field: 'pagerank', + function: ['saturation' => ['pivot' => 8]], +); +``` + ##### Script Query Filter documents with a Painless boolean script. - Class: `\Spameri\ElasticQuery\Query\Script` diff --git a/src/Query/RankFeature.php b/src/Query/RankFeature.php new file mode 100644 index 0000000..48f9427 --- /dev/null +++ b/src/Query/RankFeature.php @@ -0,0 +1,54 @@ +|null $function Optional scoring function: + * ['saturation' => ['pivot' => 8]] | ['log' => ['scaling_factor' => 4]] + * | ['sigmoid' => ['pivot' => 7, 'exponent' => 0.6]] | ['linear' => new \stdClass()]. + */ + public function __construct( + private string $field, + private array|null $function = null, + private float $boost = 1.0, + ) + { + } + + + public function key(): string + { + return 'rank_feature_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'field' => $this->field, + 'boost' => $this->boost, + ]; + + if ($this->function !== null) { + foreach ($this->function as $name => $config) { + $body[$name] = $config; + } + } + + return [ + 'rank_feature' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/RankFeature.phpt b/tests/SpameriTests/ElasticQuery/Query/RankFeature.phpt new file mode 100644 index 0000000..0234fc0 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/RankFeature.phpt @@ -0,0 +1,61 @@ + ['pivot' => 8]], + ); + + $array = $rf->toArray(); + + \Tester\Assert::same('pagerank', $array['rank_feature']['field']); + \Tester\Assert::same(8, $array['rank_feature']['saturation']['pivot']); + } + + + public function testKey(): void + { + $rf = new \Spameri\ElasticQuery\Query\RankFeature('pagerank'); + + \Tester\Assert::same('rank_feature_pagerank', $rf->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new RankFeature())->run(); From f354b6b315db4b8e1b1a91f2d542cc8c3761624b Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:52:45 +0200 Subject: [PATCH 67/97] feat(query): add distance feature query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 14 ++++ src/Query/DistanceFeature.php | 47 ++++++++++++ .../ElasticQuery/Query/DistanceFeature.phpt | 76 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/Query/DistanceFeature.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/DistanceFeature.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 3bc01ee..6bcb6eb 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -532,6 +532,20 @@ new \Spameri\ElasticQuery\Query\RankFeature( ); ``` +##### DistanceFeature Query +Boost documents whose date or geo_point is close to an origin. +- Class: `\Spameri\ElasticQuery\Query\DistanceFeature` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-distance-feature-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/DistanceFeature.php) + +```php +new \Spameri\ElasticQuery\Query\DistanceFeature( + field: 'production_date', + origin: 'now', + pivot: '7d', +); +``` + ##### Script Query Filter documents with a Painless boolean script. - Class: `\Spameri\ElasticQuery\Query\Script` diff --git a/src/Query/DistanceFeature.php b/src/Query/DistanceFeature.php new file mode 100644 index 0000000..0a0a9b8 --- /dev/null +++ b/src/Query/DistanceFeature.php @@ -0,0 +1,47 @@ +|string $origin Either [lat, lon] for geo_point or a date string for date. + */ + public function __construct( + private string $field, + private array|string $origin, + private string $pivot, + private float $boost = 1.0, + ) + { + } + + + public function key(): string + { + return 'distance_feature_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'distance_feature' => [ + 'field' => $this->field, + 'origin' => $this->origin, + 'pivot' => $this->pivot, + 'boost' => $this->boost, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/DistanceFeature.phpt b/tests/SpameriTests/ElasticQuery/Query/DistanceFeature.phpt new file mode 100644 index 0000000..9a0a499 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/DistanceFeature.phpt @@ -0,0 +1,76 @@ +toArray(); + + \Tester\Assert::same('now', $array['distance_feature']['origin']); + \Tester\Assert::same('7d', $array['distance_feature']['pivot']); + } + + + public function testToArrayGeo(): void + { + $df = new \Spameri\ElasticQuery\Query\DistanceFeature( + field: 'location', + origin: [50.0, 14.4], + pivot: '1000m', + ); + + $array = $df->toArray(); + + \Tester\Assert::same([50.0, 14.4], $array['distance_feature']['origin']); + } + + + public function testKey(): void + { + $df = new \Spameri\ElasticQuery\Query\DistanceFeature('location', [0, 0], '1km'); + + \Tester\Assert::same('distance_feature_location', $df->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new DistanceFeature())->run(); From 8da126d7af89426f52094cf1512694b9b3b0a6c6 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:53:19 +0200 Subject: [PATCH 68/97] feat(query): add pinned query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/Pinned.php | 59 ++++++++++++++ .../ElasticQuery/Query/Pinned.phpt | 77 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/Query/Pinned.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Pinned.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 6bcb6eb..b960b9e 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -546,6 +546,19 @@ new \Spameri\ElasticQuery\Query\DistanceFeature( ); ``` +##### Pinned Query +Promote specific document IDs above an organic query's results. +- Class: `\Spameri\ElasticQuery\Query\Pinned` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-pinned-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Pinned.php) + +```php +new \Spameri\ElasticQuery\Query\Pinned( + organic: new \Spameri\ElasticQuery\Query\ElasticMatch('content', 'elasticsearch'), + ids: ['1', '4', '100'], +); +``` + ##### Script Query Filter documents with a Painless boolean script. - Class: `\Spameri\ElasticQuery\Query\Script` diff --git a/src/Query/Pinned.php b/src/Query/Pinned.php new file mode 100644 index 0000000..95b88cf --- /dev/null +++ b/src/Query/Pinned.php @@ -0,0 +1,59 @@ + $ids IDs to pin (use either $ids or $docs). + * @param array> $docs Document references: [['_index' => ..., '_id' => ...]]. + */ + public function __construct( + private \Spameri\ElasticQuery\Query\LeafQueryInterface $organic, + private array $ids = [], + private array $docs = [], + ) + { + if ($ids === [] && $docs === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Pinned query requires either ids or docs to pin.', + ); + } + } + + + public function key(): string + { + return 'pinned_' . $this->organic->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'organic' => $this->organic->toArray(), + ]; + + if ($this->ids !== []) { + $body['ids'] = $this->ids; + } + + if ($this->docs !== []) { + $body['docs'] = $this->docs; + } + + return [ + 'pinned' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Pinned.phpt b/tests/SpameriTests/ElasticQuery/Query/Pinned.phpt new file mode 100644 index 0000000..351a814 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Pinned.phpt @@ -0,0 +1,77 @@ +toArray(); + + \Tester\Assert::same(['1', '4', '100'], $array['pinned']['ids']); + \Tester\Assert::true(isset($array['pinned']['organic']['match'])); + } + + + public function testRequiresIdsOrDocs(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\Pinned( + new \Spameri\ElasticQuery\Query\MatchAll(), + ); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $pinned = new \Spameri\ElasticQuery\Query\Pinned( + organic: new \Spameri\ElasticQuery\Query\MatchAll(), + ids: ['1'], + ); + + \Tester\Assert::same('pinned_match_all', $pinned->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Pinned())->run(); From fcefba05fcfa94c2fa92cc289268d2f5985e5277 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:53:52 +0200 Subject: [PATCH 69/97] feat(query): add percolate query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 +++ src/Query/Percolate.php | 59 ++++++++++++ .../ElasticQuery/Query/Percolate.phpt | 89 +++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/Query/Percolate.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Percolate.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index b960b9e..ca9ced4 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -559,6 +559,19 @@ new \Spameri\ElasticQuery\Query\Pinned( ); ``` +##### Percolate Query +Match a document against stored queries (reverse search). +- Class: `\Spameri\ElasticQuery\Query\Percolate` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-percolate-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Percolate.php) + +```php +new \Spameri\ElasticQuery\Query\Percolate( + field: 'query', + document: ['message' => 'A new bonsai tree'], +); +``` + ##### Script Query Filter documents with a Painless boolean script. - Class: `\Spameri\ElasticQuery\Query\Script` diff --git a/src/Query/Percolate.php b/src/Query/Percolate.php new file mode 100644 index 0000000..230186a --- /dev/null +++ b/src/Query/Percolate.php @@ -0,0 +1,59 @@ +|null $document Inline document to percolate. + */ + public function __construct( + private string $field, + private array|null $document = null, + private string|null $index = null, + private string|null $id = null, + ) + { + if ($document === null && ($index === null || $id === null)) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Percolate query requires either a document, or both index and id.', + ); + } + } + + + public function key(): string + { + return 'percolate_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'field' => $this->field, + ]; + + if ($this->document !== null) { + $body['document'] = $this->document; + + } else { + $body['index'] = $this->index; + $body['id'] = $this->id; + } + + return [ + 'percolate' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Percolate.phpt b/tests/SpameriTests/ElasticQuery/Query/Percolate.phpt new file mode 100644 index 0000000..30a9d6d --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Percolate.phpt @@ -0,0 +1,89 @@ + 'A new bonsai tree'], + ); + + $array = $percolate->toArray(); + + \Tester\Assert::same(['message' => 'A new bonsai tree'], $array['percolate']['document']); + } + + + public function testToArrayById(): void + { + $percolate = new \Spameri\ElasticQuery\Query\Percolate( + field: 'query', + index: 'my-index', + id: '1', + ); + + $array = $percolate->toArray(); + + \Tester\Assert::same('my-index', $array['percolate']['index']); + \Tester\Assert::same('1', $array['percolate']['id']); + } + + + public function testRequiresDocOrId(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\Percolate(field: 'query'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $percolate = new \Spameri\ElasticQuery\Query\Percolate( + field: 'query', + document: ['m' => 'hello'], + ); + + \Tester\Assert::same('percolate_query', $percolate->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Percolate())->run(); From e84ef0ca2a9b77caec3c3338351887f9f607c717 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:54:27 +0200 Subject: [PATCH 70/97] feat(query): add wrapper query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 10 ++++ src/Query/Wrapper.php | 40 +++++++++++++ .../ElasticQuery/Query/Wrapper.phpt | 58 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/Query/Wrapper.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Wrapper.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index ca9ced4..0f406eb 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -572,6 +572,16 @@ new \Spameri\ElasticQuery\Query\Percolate( ); ``` +##### Wrapper Query +Embed a raw JSON query string (auto-base64-encoded) — escape hatch for unsupported syntax. +- Class: `\Spameri\ElasticQuery\Query\Wrapper` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wrapper-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Wrapper.php) + +```php +new \Spameri\ElasticQuery\Query\Wrapper('{"term":{"user":{"value":"kimchy"}}}'); +``` + ##### Script Query Filter documents with a Painless boolean script. - Class: `\Spameri\ElasticQuery\Query\Script` diff --git a/src/Query/Wrapper.php b/src/Query/Wrapper.php new file mode 100644 index 0000000..d26453f --- /dev/null +++ b/src/Query/Wrapper.php @@ -0,0 +1,40 @@ +encoded = \base64_encode($rawJsonQuery); + } + + + public function key(): string + { + return 'wrapper_' . \substr($this->encoded, 0, 12); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'wrapper' => [ + 'query' => $this->encoded, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Wrapper.phpt b/tests/SpameriTests/ElasticQuery/Query/Wrapper.phpt new file mode 100644 index 0000000..041f32e --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Wrapper.phpt @@ -0,0 +1,58 @@ +toArray(); + + \Tester\Assert::same(\base64_encode($raw), $array['wrapper']['query']); + } + + + public function testKey(): void + { + $wrapper = new \Spameri\ElasticQuery\Query\Wrapper('{}'); + + \Tester\Assert::contains('wrapper_', $wrapper->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new Wrapper())->run(); From 813f6c3a52573bc764e0ed299155ebb4f51c41e0 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:54:56 +0200 Subject: [PATCH 71/97] feat(query): add span term query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 16 ++++++ src/Query/SpanTerm.php | 43 ++++++++++++++ .../ElasticQuery/Query/SpanTerm.phpt | 57 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/Query/SpanTerm.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanTerm.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 0f406eb..791a2eb 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -597,6 +597,22 @@ new \Spameri\ElasticQuery\Query\Script( --- +## Span Queries + +Positional matching — useful for term-position-aware searches. Span clauses can be composed via the `*_near`, `*_or`, etc. queries. + +##### SpanTerm Query +Match a single term in a span-aware way. +- Class: `\Spameri\ElasticQuery\Query\SpanTerm` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-term-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanTerm.php) + +```php +new \Spameri\ElasticQuery\Query\SpanTerm(field: 'text', query: 'quick'); +``` + +--- + ## Boolean Query Collections These collections implement `LeafQueryInterface` and can be nested arbitrarily. diff --git a/src/Query/SpanTerm.php b/src/Query/SpanTerm.php new file mode 100644 index 0000000..c9412df --- /dev/null +++ b/src/Query/SpanTerm.php @@ -0,0 +1,43 @@ +field . '_' . $this->query; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + return [ + 'span_term' => [ + $this->field => [ + 'value' => $this->query, + 'boost' => $this->boost, + ], + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanTerm.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanTerm.phpt new file mode 100644 index 0000000..17ad8d1 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanTerm.phpt @@ -0,0 +1,57 @@ +toArray(); + + \Tester\Assert::same('quick', $array['span_term']['text']['value']); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanTerm('text', 'quick'); + + \Tester\Assert::same('span_term_text_quick', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanTerm())->run(); From d0eecef1c99167b0f13b386497f2978b7e4b6659 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:55:28 +0200 Subject: [PATCH 72/97] feat(query): add span first query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/SpanFirst.php | 40 ++++++++++++ .../ElasticQuery/Query/SpanFirst.phpt | 64 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 src/Query/SpanFirst.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanFirst.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 791a2eb..7fb68c0 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -601,6 +601,19 @@ new \Spameri\ElasticQuery\Query\Script( Positional matching — useful for term-position-aware searches. Span clauses can be composed via the `*_near`, `*_or`, etc. queries. +##### SpanFirst Query +Match a span only if it appears within the first N positions. +- Class: `\Spameri\ElasticQuery\Query\SpanFirst` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-first-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanFirst.php) + +```php +new \Spameri\ElasticQuery\Query\SpanFirst( + match: new \Spameri\ElasticQuery\Query\SpanTerm('user', 'kimchy'), + end: 3, +); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/SpanFirst.php b/src/Query/SpanFirst.php new file mode 100644 index 0000000..95ca74b --- /dev/null +++ b/src/Query/SpanFirst.php @@ -0,0 +1,40 @@ +match->key() . '_' . $this->end; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'span_first' => [ + 'match' => $this->match->toArray(), + 'end' => $this->end, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanFirst.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanFirst.phpt new file mode 100644 index 0000000..0beb8f2 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanFirst.phpt @@ -0,0 +1,64 @@ +toArray(); + + \Tester\Assert::same(3, $array['span_first']['end']); + \Tester\Assert::true(isset($array['span_first']['match']['span_term'])); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanFirst( + new \Spameri\ElasticQuery\Query\SpanTerm('user', 'kimchy'), + 3, + ); + + \Tester\Assert::same('span_first_span_term_user_kimchy_3', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanFirst())->run(); From 17992b6ad920bf045ee8ecfd15035c158af0e95f Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:56:04 +0200 Subject: [PATCH 73/97] feat(query): add span near query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 15 +++++ src/Query/SpanNear.php | 65 ++++++++++++++++++ .../ElasticQuery/Query/SpanNear.phpt | 66 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/Query/SpanNear.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanNear.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 7fb68c0..62ecba2 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -614,6 +614,21 @@ new \Spameri\ElasticQuery\Query\SpanFirst( ); ``` +##### SpanNear Query +Match multiple span clauses within `slop` positions of each other. +- Class: `\Spameri\ElasticQuery\Query\SpanNear` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-near-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanNear.php) + +```php +$span = new \Spameri\ElasticQuery\Query\SpanNear( + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value1'), + slop: 12, + inOrder: false, +); +$span->addClause(new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value2')); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/SpanNear.php b/src/Query/SpanNear.php new file mode 100644 index 0000000..98ff5c0 --- /dev/null +++ b/src/Query/SpanNear.php @@ -0,0 +1,65 @@ + + */ + private array $clauses; + + + public function __construct( + \Spameri\ElasticQuery\Query\LeafQueryInterface $clause, + private int $slop = 0, + private bool $inOrder = true, + ) + { + $this->clauses = [$clause]; + } + + + public function addClause(\Spameri\ElasticQuery\Query\LeafQueryInterface $clause): void + { + $this->clauses[] = $clause; + } + + + public function key(): string + { + $keys = []; + foreach ($this->clauses as $clause) { + $keys[] = $clause->key(); + } + + return 'span_near_' . \implode('-', $keys); + } + + + /** + * @return array> + */ + public function toArray(): array + { + $clauses = []; + foreach ($this->clauses as $clause) { + $clauses[] = $clause->toArray(); + } + + return [ + 'span_near' => [ + 'clauses' => $clauses, + 'slop' => $this->slop, + 'in_order' => $this->inOrder, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanNear.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanNear.phpt new file mode 100644 index 0000000..335fb8e --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanNear.phpt @@ -0,0 +1,66 @@ +addClause(new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value2')); + + $array = $span->toArray(); + + \Tester\Assert::same(12, $array['span_near']['slop']); + \Tester\Assert::false($array['span_near']['in_order']); + \Tester\Assert::count(2, $array['span_near']['clauses']); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanNear( + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value1'), + ); + + \Tester\Assert::same('span_near_span_term_field_value1', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanNear())->run(); From a207f5cc6a70ef63a4ba00835ad72062c54b7574 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:56:36 +0200 Subject: [PATCH 74/97] feat(query): add span or query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/SpanOr.php | 59 ++++++++++++++++++ .../ElasticQuery/Query/SpanOr.phpt | 62 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 src/Query/SpanOr.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanOr.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 62ecba2..9145b11 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -629,6 +629,19 @@ $span = new \Spameri\ElasticQuery\Query\SpanNear( $span->addClause(new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value2')); ``` +##### SpanOr Query +Match any of several span clauses. +- Class: `\Spameri\ElasticQuery\Query\SpanOr` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-or-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanOr.php) + +```php +$span = new \Spameri\ElasticQuery\Query\SpanOr( + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value1'), +); +$span->addClause(new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value2')); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/SpanOr.php b/src/Query/SpanOr.php new file mode 100644 index 0000000..28fe3f7 --- /dev/null +++ b/src/Query/SpanOr.php @@ -0,0 +1,59 @@ + + */ + private array $clauses; + + + public function __construct(\Spameri\ElasticQuery\Query\LeafQueryInterface $clause) + { + $this->clauses = [$clause]; + } + + + public function addClause(\Spameri\ElasticQuery\Query\LeafQueryInterface $clause): void + { + $this->clauses[] = $clause; + } + + + public function key(): string + { + $keys = []; + foreach ($this->clauses as $clause) { + $keys[] = $clause->key(); + } + + return 'span_or_' . \implode('-', $keys); + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $clauses = []; + foreach ($this->clauses as $clause) { + $clauses[] = $clause->toArray(); + } + + return [ + 'span_or' => [ + 'clauses' => $clauses, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanOr.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanOr.phpt new file mode 100644 index 0000000..168860f --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanOr.phpt @@ -0,0 +1,62 @@ +addClause(new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value2')); + + $array = $span->toArray(); + + \Tester\Assert::count(2, $array['span_or']['clauses']); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanOr( + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value1'), + ); + + \Tester\Assert::same('span_or_span_term_field_value1', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanOr())->run(); From dffc919ec34bc25edff2bf15ba06c1c5d680fcb1 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:57:08 +0200 Subject: [PATCH 75/97] feat(query): add span not query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 15 ++++ src/Query/SpanNot.php | 57 ++++++++++++++++ .../ElasticQuery/Query/SpanNot.phpt | 68 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 src/Query/SpanNot.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanNot.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 9145b11..1d87fa6 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -642,6 +642,21 @@ $span = new \Spameri\ElasticQuery\Query\SpanOr( $span->addClause(new \Spameri\ElasticQuery\Query\SpanTerm('field', 'value2')); ``` +##### SpanNot Query +Match `include` spans not overlapping `exclude` spans. +- Class: `\Spameri\ElasticQuery\Query\SpanNot` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-not-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanNot.php) + +```php +new \Spameri\ElasticQuery\Query\SpanNot( + include: new \Spameri\ElasticQuery\Query\SpanTerm('field', 'hot'), + exclude: new \Spameri\ElasticQuery\Query\SpanTerm('field', 'dog'), + pre: 0, + post: 1, +); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/SpanNot.php b/src/Query/SpanNot.php new file mode 100644 index 0000000..dbb2f09 --- /dev/null +++ b/src/Query/SpanNot.php @@ -0,0 +1,57 @@ +include->key() . '_' . $this->exclude->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'include' => $this->include->toArray(), + 'exclude' => $this->exclude->toArray(), + ]; + + if ($this->pre !== null) { + $body['pre'] = $this->pre; + } + + if ($this->post !== null) { + $body['post'] = $this->post; + } + + if ($this->dist !== null) { + $body['dist'] = $this->dist; + } + + return [ + 'span_not' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanNot.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanNot.phpt new file mode 100644 index 0000000..3b1ec71 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanNot.phpt @@ -0,0 +1,68 @@ +toArray(); + + \Tester\Assert::same(0, $array['span_not']['pre']); + \Tester\Assert::same(1, $array['span_not']['post']); + \Tester\Assert::true(isset($array['span_not']['include'])); + \Tester\Assert::true(isset($array['span_not']['exclude'])); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanNot( + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'hot'), + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'dog'), + ); + + \Tester\Assert::same('span_not_span_term_field_hot_span_term_field_dog', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanNot())->run(); From e86210a5ca67378c2b6718cf9c96a0e38c8f4186 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:57:37 +0200 Subject: [PATCH 76/97] feat(query): add span containing query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/SpanContaining.php | 40 ++++++++++++ .../ElasticQuery/Query/SpanContaining.phpt | 64 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 src/Query/SpanContaining.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanContaining.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 1d87fa6..2147796 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -657,6 +657,19 @@ new \Spameri\ElasticQuery\Query\SpanNot( ); ``` +##### SpanContaining Query +Match `big` spans that fully enclose `little` spans. +- Class: `\Spameri\ElasticQuery\Query\SpanContaining` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-containing-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanContaining.php) + +```php +new \Spameri\ElasticQuery\Query\SpanContaining( + big: $bigSpan, + little: $littleSpan, +); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/SpanContaining.php b/src/Query/SpanContaining.php new file mode 100644 index 0000000..e22a665 --- /dev/null +++ b/src/Query/SpanContaining.php @@ -0,0 +1,40 @@ +big->key() . '_' . $this->little->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'span_containing' => [ + 'big' => $this->big->toArray(), + 'little' => $this->little->toArray(), + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanContaining.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanContaining.phpt new file mode 100644 index 0000000..9c719b2 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanContaining.phpt @@ -0,0 +1,64 @@ +toArray(); + + \Tester\Assert::true(isset($array['span_containing']['big'])); + \Tester\Assert::true(isset($array['span_containing']['little'])); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanContaining( + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'a'), + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'b'), + ); + + \Tester\Assert::same('span_containing_span_term_field_a_span_term_field_b', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanContaining())->run(); From 83ae5ee1241b2dfc45a69db5951f878e7c7a3ba9 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:58:08 +0200 Subject: [PATCH 77/97] feat(query): add span within query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/SpanWithin.php | 40 ++++++++++++ .../ElasticQuery/Query/SpanWithin.phpt | 64 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 src/Query/SpanWithin.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanWithin.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 2147796..472025f 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -670,6 +670,19 @@ new \Spameri\ElasticQuery\Query\SpanContaining( ); ``` +##### SpanWithin Query +Match `little` spans that are fully enclosed within `big` spans. +- Class: `\Spameri\ElasticQuery\Query\SpanWithin` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-within-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanWithin.php) + +```php +new \Spameri\ElasticQuery\Query\SpanWithin( + big: $bigSpan, + little: $littleSpan, +); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/SpanWithin.php b/src/Query/SpanWithin.php new file mode 100644 index 0000000..00e262c --- /dev/null +++ b/src/Query/SpanWithin.php @@ -0,0 +1,40 @@ +big->key() . '_' . $this->little->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'span_within' => [ + 'big' => $this->big->toArray(), + 'little' => $this->little->toArray(), + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanWithin.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanWithin.phpt new file mode 100644 index 0000000..e2d06c8 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanWithin.phpt @@ -0,0 +1,64 @@ +toArray(); + + \Tester\Assert::true(isset($array['span_within']['big'])); + \Tester\Assert::true(isset($array['span_within']['little'])); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanWithin( + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'a'), + new \Spameri\ElasticQuery\Query\SpanTerm('field', 'b'), + ); + + \Tester\Assert::same('span_within_span_term_field_a_span_term_field_b', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanWithin())->run(); From a4015e237b9ecb70f7e91715bffd2b32a69cf8be Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 14:59:59 +0200 Subject: [PATCH 78/97] feat(query): add span multi query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 12 ++++ src/Query/SpanMulti.php | 38 ++++++++++++ .../ElasticQuery/Query/SpanMulti.phpt | 61 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 src/Query/SpanMulti.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/SpanMulti.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 472025f..0f69015 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -683,6 +683,18 @@ new \Spameri\ElasticQuery\Query\SpanWithin( ); ``` +##### SpanMulti Query +Wrap a multi-term query (prefix/wildcard/regexp/fuzzy/range) so it can be used inside other span queries. +- Class: `\Spameri\ElasticQuery\Query\SpanMulti` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-multi-term-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/SpanMulti.php) + +```php +new \Spameri\ElasticQuery\Query\SpanMulti( + match: new \Spameri\ElasticQuery\Query\Prefix(field: 'user', query: 'ki'), +); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/SpanMulti.php b/src/Query/SpanMulti.php new file mode 100644 index 0000000..a61e1a6 --- /dev/null +++ b/src/Query/SpanMulti.php @@ -0,0 +1,38 @@ +match->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'span_multi' => [ + 'match' => $this->match->toArray(), + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/SpanMulti.phpt b/tests/SpameriTests/ElasticQuery/Query/SpanMulti.phpt new file mode 100644 index 0000000..49d0f88 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SpanMulti.phpt @@ -0,0 +1,61 @@ +toArray(); + + \Tester\Assert::true(isset($array['span_multi']['match']['prefix'])); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\SpanMulti( + match: new \Spameri\ElasticQuery\Query\Prefix('user', 'ki'), + ); + + \Tester\Assert::same('span_multi_prefix_user_ki', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new SpanMulti())->run(); From 9ce1816a8e81eaeae0334911421ae9000a8a079c Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 15:00:28 +0200 Subject: [PATCH 79/97] feat(query): add field masking span query Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/02-query-objects.md | 13 ++++ src/Query/FieldMaskingSpan.php | 40 ++++++++++++ .../ElasticQuery/Query/FieldMaskingSpan.phpt | 64 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 src/Query/FieldMaskingSpan.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/FieldMaskingSpan.phpt diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index 0f69015..d6f7daf 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -695,6 +695,19 @@ new \Spameri\ElasticQuery\Query\SpanMulti( ); ``` +##### FieldMaskingSpan Query +Mask a span clause to act on a different field — enables span queries across multiple fields. +- Class: `\Spameri\ElasticQuery\Query\FieldMaskingSpan` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-span-field-masking-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/FieldMaskingSpan.php) + +```php +new \Spameri\ElasticQuery\Query\FieldMaskingSpan( + query: new \Spameri\ElasticQuery\Query\SpanTerm('text.stems', 'fox'), + field: 'text', +); +``` + ##### SpanTerm Query Match a single term in a span-aware way. - Class: `\Spameri\ElasticQuery\Query\SpanTerm` diff --git a/src/Query/FieldMaskingSpan.php b/src/Query/FieldMaskingSpan.php new file mode 100644 index 0000000..b2f187b --- /dev/null +++ b/src/Query/FieldMaskingSpan.php @@ -0,0 +1,40 @@ +field . '_' . $this->query->key(); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'field_masking_span' => [ + 'query' => $this->query->toArray(), + 'field' => $this->field, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/FieldMaskingSpan.phpt b/tests/SpameriTests/ElasticQuery/Query/FieldMaskingSpan.phpt new file mode 100644 index 0000000..e5a0474 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/FieldMaskingSpan.phpt @@ -0,0 +1,64 @@ +toArray(); + + \Tester\Assert::same('text', $array['field_masking_span']['field']); + \Tester\Assert::true(isset($array['field_masking_span']['query']['span_term'])); + } + + + public function testKey(): void + { + $span = new \Spameri\ElasticQuery\Query\FieldMaskingSpan( + new \Spameri\ElasticQuery\Query\SpanTerm('text.stems', 'fox'), + 'text', + ); + + \Tester\Assert::same('field_masking_span_text_span_term_text.stems_fox', $span->key()); + } + + + public function tearDown(): void + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + \curl_exec($ch); + } + +} + +(new FieldMaskingSpan())->run(); From 641abafc7d6504a8c0b65c3a628325d67426b7d5 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 16:28:46 +0200 Subject: [PATCH 80/97] test: add AbstractElasticTestCase to remove curl boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each .phpt currently duplicates ~60 lines of curl setUp/tearDown plus the request-build-send-decode-map flow. The new base class provides createIndex/indexDocument/search/deleteIndex helpers and default setUp/tearDown, cutting a typical integration test to ~15 lines. Term.phpt migrated as proof — round-trips against ES via the new helpers, and asserts a real hit count instead of just type('int'). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ElasticQuery/AbstractElasticTestCase.php | 119 ++++++++++++++++++ .../SpameriTests/ElasticQuery/Query/Term.phpt | 76 +++-------- 2 files changed, 137 insertions(+), 58 deletions(-) create mode 100644 tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php diff --git a/tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php b/tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php new file mode 100644 index 0000000..bef9166 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php @@ -0,0 +1,119 @@ +createIndex($this->mapping()); + } + + + public function tearDown(): void + { + $this->deleteIndex(); + } + + + /** + * @return array|null Override to provide {settings, mappings} for the index. + */ + protected function mapping(): array|null + { + return null; + } + + + /** + * @param array|null $mapping + */ + protected function createIndex(array|null $mapping = null): void + { + $this->request('PUT', static::INDEX, $mapping); + } + + + protected function deleteIndex(): void + { + $this->request('DELETE', static::INDEX); + } + + + /** + * @param array $body + */ + protected function indexDocument( + array $body, + string|null $id = null, + bool $refresh = true, + ): void + { + $suffix = $id !== null ? '/_doc/' . \rawurlencode($id) : '/_doc'; + if ($refresh) { + $suffix .= '?refresh=true'; + } + + $this->request($id !== null ? 'PUT' : 'POST', static::INDEX . $suffix, $body); + } + + + protected function search( + \Spameri\ElasticQuery\ElasticQuery $query, + ): \Spameri\ElasticQuery\Response\ResultSearch + { + $response = $this->request('POST', static::INDEX . '/_search', $query->toArray()); + + $result = (new \Spameri\ElasticQuery\Response\ResultMapper())->map($response); + \assert($result instanceof \Spameri\ElasticQuery\Response\ResultSearch); + + return $result; + } + + + /** + * @param array|null $body + * @return array + */ + protected function request( + string $method, + string $path, + array|null $body = null, + ): array + { + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $path); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $method); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + + if ($body !== null) { + \curl_setopt($ch, \CURLOPT_POSTFIELDS, (string) \json_encode($body)); + } + + $response = \curl_exec($ch); + \curl_close($ch); + + if ($response === false) { + return []; + } + + $decoded = \json_decode((string) $response, true); + + return \is_array($decoded) ? $decoded : []; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Term.phpt b/tests/SpameriTests/ElasticQuery/Query/Term.phpt index b445579..1307e11 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Term.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Term.phpt @@ -5,83 +5,43 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Term extends \Tester\TestCase +class Term extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_term'; + protected const INDEX = 'spameri_test_query_term'; - public function setUp() : void - { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } - - - public function testCreate() : void + public function testToArray(): void { $term = new \Spameri\ElasticQuery\Query\Term( 'name', 'Avengers', - 1.0 + 1.0, ); $array = $term->toArray(); - \Tester\Assert::true(isset($array['term']['name']['value'])); \Tester\Assert::same('Avengers', $array['term']['name']['value']); \Tester\Assert::same(1.0, $array['term']['name']['boost']); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - NULL, - new \Spameri\ElasticQuery\Query\MustCollection( - $term - ) - ) - ) - )->toArray() - ) - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']) - ); - - \Tester\Assert::noError(static function () use ($ch) { - $response = curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, TRUE)); - \Tester\Assert::type('int', $result->stats()->total()); - }); } - public function tearDown() : void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['name' => 'Avengers']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Term('name.keyword', 'Avengers'), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } From 8efe6733e68b34a7b89e4d76e21879f8fb0aa68a Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 16:35:18 +0200 Subject: [PATCH 81/97] fix(query): correct geo_distance, nested envelope; add inner_hits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeoDistance was emitting {pin: {location: ...}} which Elasticsearch rejects — fixed to emit the proper geo_distance envelope with the required distance argument, plus distance_type/validation_method/ ignore_unmapped/boost. The old test asserted the broken shape, so the bug shipped silently. Nested was wrapping query in an extra array level (query: [bool: ...] instead of query: bool: ...) which Elasticsearch also rejects. Added score_mode/ignore_unmapped/inner_hits. The empty-collection case now emits an stdClass to keep ES happy. InnerHits added as a typed sub-object (name, from, size, sort, _source, highlight, explain, script_fields, docvalue_fields, etc.) so nested/has_child/has_parent can use it. PhrasePrefix boost type changed from int to float for consistency with every other query. GeoDistanceSort ignore_unmapped is now a constructor arg instead of hard-coded true. Tests now use AbstractElasticTestCase, indexing real docs through a typed mapping and asserting the round-tripped hit count. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Options/GeoDistanceSort.php | 14 ++- src/Query/GeoDistance.php | 39 +++++-- src/Query/InnerHits.php | 102 ++++++++++++++++ src/Query/Nested.php | 45 +++++-- src/Query/PhrasePrefix.php | 2 +- .../ElasticQuery/Options/GeoDistanceSort.phpt | 15 +++ .../ElasticQuery/Query/GeoDistance.phpt | 97 ++++++++------- .../ElasticQuery/Query/Nested.phpt | 110 +++++++++--------- .../ElasticQuery/Query/PhrasePrefix.phpt | 6 +- 9 files changed, 307 insertions(+), 123 deletions(-) create mode 100644 src/Query/InnerHits.php diff --git a/src/Options/GeoDistanceSort.php b/src/Options/GeoDistanceSort.php index fcbbdb1..1b05dc4 100644 --- a/src/Options/GeoDistanceSort.php +++ b/src/Options/GeoDistanceSort.php @@ -6,7 +6,7 @@ /** - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.3/search-request-body.html#geo-sorting + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#geo-sorting */ readonly class GeoDistanceSort implements \Spameri\ElasticQuery\Entity\EntityInterface { @@ -19,6 +19,7 @@ public function __construct( public string $unit = 'km', public string $mode = 'min', public string $distanceType = 'arc', + public bool $ignoreUnmapped = true, ) { if ( ! \in_array($type, [Sort::ASC, Sort::DESC], true)) { @@ -35,6 +36,9 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { return [ @@ -44,10 +48,10 @@ public function toArray(): array $this->lon, ], 'order' => $this->type, - "unit" => $this->unit, - "mode" => $this->mode, - "distance_type" => $this->distanceType, - "ignore_unmapped" => true, + 'unit' => $this->unit, + 'mode' => $this->mode, + 'distance_type' => $this->distanceType, + 'ignore_unmapped' => $this->ignoreUnmapped, ], ]; } diff --git a/src/Query/GeoDistance.php b/src/Query/GeoDistance.php index a07120f..c3dcc15 100644 --- a/src/Query/GeoDistance.php +++ b/src/Query/GeoDistance.php @@ -15,27 +15,50 @@ public function __construct( private string $field, private float $lat, private float $lon, + private string $distance, + private string|null $distanceType = null, + private string|null $validationMethod = null, + private bool|null $ignoreUnmapped = null, + private float $boost = 1.0, ) { - } public function key(): string { - return 'geo_distance_' . $this->field . '_' . $this->lat . '.' . $this->lon; + return 'geo_distance_' . $this->field . '_' . $this->lat . '.' . $this->lon . '_' . $this->distance; } + /** + * @return array> + */ public function toArray(): array { - return [ - 'pin' => [ - 'location' => [ - 'lat' => $this->lat, - 'lon' => $this->lon, - ], + $body = [ + 'distance' => $this->distance, + $this->field => [ + 'lat' => $this->lat, + 'lon' => $this->lon, ], + 'boost' => $this->boost, + ]; + + if ($this->distanceType !== null) { + $body['distance_type'] = $this->distanceType; + } + + if ($this->validationMethod !== null) { + $body['validation_method'] = $this->validationMethod; + } + + if ($this->ignoreUnmapped !== null) { + $body['ignore_unmapped'] = $this->ignoreUnmapped; + } + + return [ + 'geo_distance' => $body, ]; } diff --git a/src/Query/InnerHits.php b/src/Query/InnerHits.php new file mode 100644 index 0000000..200fc49 --- /dev/null +++ b/src/Query/InnerHits.php @@ -0,0 +1,102 @@ +> $sort + * @param bool|array|array> $source + * @param array $scriptFields + * @param array $docvalueFields + * @param array $storedFields + */ + public function __construct( + private string|null $name = null, + private int|null $from = null, + private int|null $size = null, + private array $sort = [], + private bool|array $source = true, + private \Spameri\ElasticQuery\Highlight|null $highlight = null, + private bool|null $explain = null, + private array $scriptFields = [], + private array $docvalueFields = [], + private bool|null $version = null, + private bool|null $seqNoPrimaryTerm = null, + private array $storedFields = [], + private bool|null $trackScores = null, + ) + { + } + + + /** + * @return array + */ + public function toArray(): array + { + $array = []; + + if ($this->name !== null) { + $array['name'] = $this->name; + } + + if ($this->from !== null) { + $array['from'] = $this->from; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->sort !== []) { + $array['sort'] = $this->sort; + } + + if ($this->source !== true) { + $array['_source'] = $this->source; + } + + if ($this->highlight !== null) { + $array['highlight'] = $this->highlight->toArray(); + } + + if ($this->explain !== null) { + $array['explain'] = $this->explain; + } + + if ($this->scriptFields !== []) { + $array['script_fields'] = $this->scriptFields; + } + + if ($this->docvalueFields !== []) { + $array['docvalue_fields'] = $this->docvalueFields; + } + + if ($this->version !== null) { + $array['version'] = $this->version; + } + + if ($this->seqNoPrimaryTerm !== null) { + $array['seq_no_primary_term'] = $this->seqNoPrimaryTerm; + } + + if ($this->storedFields !== []) { + $array['stored_fields'] = $this->storedFields; + } + + if ($this->trackScores !== null) { + $array['track_scores'] = $this->trackScores; + } + + return $array; + } + +} diff --git a/src/Query/Nested.php b/src/Query/Nested.php index 532f81a..6816362 100644 --- a/src/Query/Nested.php +++ b/src/Query/Nested.php @@ -4,18 +4,30 @@ namespace Spameri\ElasticQuery\Query; + +/** + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-nested-query.html + */ class Nested implements \Spameri\ElasticQuery\Query\LeafQueryInterface { + public const SCORE_MODE_AVG = 'avg'; + public const SCORE_MODE_SUM = 'sum'; + public const SCORE_MODE_MIN = 'min'; + public const SCORE_MODE_MAX = 'max'; + public const SCORE_MODE_NONE = 'none'; + private \Spameri\ElasticQuery\Query\QueryCollection $query; public function __construct( private string $path, \Spameri\ElasticQuery\Query\QueryCollection|null $query = null, + private string|null $scoreMode = null, + private bool|null $ignoreUnmapped = null, + private \Spameri\ElasticQuery\Query\InnerHits|null $innerHits = null, ) { - if ($query === null) { $query = new \Spameri\ElasticQuery\Query\QueryCollection(); } @@ -30,23 +42,36 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { $queryArray = $this->query->toArray(); if (\count($queryArray) === 0) { - $queryArray = [ - 'bool' => [], - ]; + $queryArray = ['bool' => new \stdClass()]; + } + + $body = [ + 'path' => $this->path, + 'query' => $queryArray, + ]; + + if ($this->scoreMode !== null) { + $body['score_mode'] = $this->scoreMode; + } + + if ($this->ignoreUnmapped !== null) { + $body['ignore_unmapped'] = $this->ignoreUnmapped; + } + + if ($this->innerHits !== null) { + $body['inner_hits'] = $this->innerHits->toArray(); } return [ - 'nested' => [ - 'path' => $this->path, - 'query' => [ - $queryArray, - ], - ], + 'nested' => $body, ]; } diff --git a/src/Query/PhrasePrefix.php b/src/Query/PhrasePrefix.php index 0170495..56b824e 100644 --- a/src/Query/PhrasePrefix.php +++ b/src/Query/PhrasePrefix.php @@ -10,7 +10,7 @@ class PhrasePrefix implements \Spameri\ElasticQuery\Query\LeafQueryInterface public function __construct( private string $field, private string $queryString, - private int $boost = 1, + private float $boost = 1.0, private int $slop = 1, ) { diff --git a/tests/SpameriTests/ElasticQuery/Options/GeoDistanceSort.phpt b/tests/SpameriTests/ElasticQuery/Options/GeoDistanceSort.phpt index 5bd412f..d30e4fe 100644 --- a/tests/SpameriTests/ElasticQuery/Options/GeoDistanceSort.phpt +++ b/tests/SpameriTests/ElasticQuery/Options/GeoDistanceSort.phpt @@ -144,6 +144,21 @@ class GeoDistanceSort extends \Tester\TestCase } + public function testIgnoreUnmappedFalse(): void + { + $geoSort = new \Spameri\ElasticQuery\Options\GeoDistanceSort( + field: 'location', + lat: 0.0, + lon: 0.0, + ignoreUnmapped: false, + ); + + $array = $geoSort->toArray(); + + \Tester\Assert::false($array['_geo_distance']['ignore_unmapped']); + } + + public function testInvalidSortTypeThrowsException(): void { \Tester\Assert::exception( diff --git a/tests/SpameriTests/ElasticQuery/Query/GeoDistance.phpt b/tests/SpameriTests/ElasticQuery/Query/GeoDistance.phpt index 7a7aba2..618128d 100644 --- a/tests/SpameriTests/ElasticQuery/Query/GeoDistance.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/GeoDistance.phpt @@ -5,80 +5,99 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class GeoDistance extends \Tester\TestCase +class GeoDistance extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { + protected const INDEX = 'spameri_test_query_geo_distance'; + + + protected function mapping(): array|null + { + return [ + 'mappings' => [ + 'properties' => [ + 'location' => ['type' => 'geo_point'], + ], + ], + ]; + } + + public function testToArray(): void { $geoDistance = new \Spameri\ElasticQuery\Query\GeoDistance( 'location', 40.73, -74.1, + '200km', ); $array = $geoDistance->toArray(); - \Tester\Assert::true(isset($array['pin'])); - \Tester\Assert::true(isset($array['pin']['location'])); - \Tester\Assert::same(40.73, $array['pin']['location']['lat']); - \Tester\Assert::same(-74.1, $array['pin']['location']['lon']); + \Tester\Assert::same('200km', $array['geo_distance']['distance']); + \Tester\Assert::same(40.73, $array['geo_distance']['location']['lat']); + \Tester\Assert::same(-74.1, $array['geo_distance']['location']['lon']); + \Tester\Assert::same(1.0, $array['geo_distance']['boost']); } - public function testKey(): void + public function testWithAllOptions(): void { $geoDistance = new \Spameri\ElasticQuery\Query\GeoDistance( - 'position', - 51.5074, - -0.1278, - ); - - \Tester\Assert::same('geo_distance_position_51.5074.-0.1278', $geoDistance->key()); - } - - - public function testWithDifferentCoordinates(): void - { - $geoDistance = new \Spameri\ElasticQuery\Query\GeoDistance( - 'coordinates', - 48.8566, - 2.3522, + field: 'location', + lat: 48.8566, + lon: 2.3522, + distance: '10km', + distanceType: 'plane', + validationMethod: 'COERCE', + ignoreUnmapped: true, + boost: 2.0, ); $array = $geoDistance->toArray(); - \Tester\Assert::same(48.8566, $array['pin']['location']['lat']); - \Tester\Assert::same(2.3522, $array['pin']['location']['lon']); + \Tester\Assert::same('plane', $array['geo_distance']['distance_type']); + \Tester\Assert::same('COERCE', $array['geo_distance']['validation_method']); + \Tester\Assert::true($array['geo_distance']['ignore_unmapped']); + \Tester\Assert::same(2.0, $array['geo_distance']['boost']); } - public function testNegativeCoordinates(): void + public function testKey(): void { $geoDistance = new \Spameri\ElasticQuery\Query\GeoDistance( - 'location', - -33.8688, - -151.2093, + 'position', + 51.5074, + -0.1278, + '5km', ); - $array = $geoDistance->toArray(); - - \Tester\Assert::same(-33.8688, $array['pin']['location']['lat']); - \Tester\Assert::same(-151.2093, $array['pin']['location']['lon']); + \Tester\Assert::same('geo_distance_position_51.5074.-0.1278_5km', $geoDistance->key()); } - public function testZeroCoordinates(): void + public function testCreate(): void { - $geoDistance = new \Spameri\ElasticQuery\Query\GeoDistance( - 'location', - 0.0, - 0.0, + $this->indexDocument(['location' => ['lat' => 40.73, 'lon' => -74.1]]); + $this->indexDocument(['location' => ['lat' => 0.0, 'lon' => 0.0]]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\GeoDistance( + field: 'location', + lat: 40.73, + lon: -74.1, + distance: '50km', + ), + ), + ), ); - $array = $geoDistance->toArray(); + $result = $this->search($query); - \Tester\Assert::same(0.0, $array['pin']['location']['lat']); - \Tester\Assert::same(0.0, $array['pin']['location']['lon']); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Nested.phpt b/tests/SpameriTests/ElasticQuery/Query/Nested.phpt index ed9d044..823716b 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Nested.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Nested.phpt @@ -5,21 +5,27 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Nested extends \Tester\TestCase +class Nested extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_nested'; + protected const INDEX = 'spameri_test_query_nested'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'comments' => [ + 'type' => 'nested', + 'properties' => [ + 'author' => ['type' => 'keyword'], + 'text' => ['type' => 'text'], + ], + ], + ], + ], + ]; } @@ -29,9 +35,8 @@ class Nested extends \Tester\TestCase $array = $nested->toArray(); - \Tester\Assert::true(isset($array['nested'])); \Tester\Assert::same('comments', $array['nested']['path']); - \Tester\Assert::true(isset($array['nested']['query'][0]['bool'])); + \Tester\Assert::true(isset($array['nested']['query']['bool'])); } @@ -47,90 +52,81 @@ class Nested extends \Tester\TestCase $array = $nested->toArray(); \Tester\Assert::same('comments', $array['nested']['path']); - \Tester\Assert::true(isset($array['nested']['query'][0]['bool']['must'])); - \Tester\Assert::count(1, $array['nested']['query'][0]['bool']['must']); + \Tester\Assert::count(1, $array['nested']['query']['bool']['must']); } - public function testGetQuery(): void + public function testToArrayWithScoreModeAndIgnoreUnmapped(): void { - $nested = new \Spameri\ElasticQuery\Query\Nested('products'); + $nested = new \Spameri\ElasticQuery\Query\Nested( + path: 'comments', + scoreMode: \Spameri\ElasticQuery\Query\Nested::SCORE_MODE_AVG, + ignoreUnmapped: true, + ); - $query = $nested->getQuery(); + $array = $nested->toArray(); - \Tester\Assert::type(\Spameri\ElasticQuery\Query\QueryCollection::class, $query); + \Tester\Assert::same('avg', $array['nested']['score_mode']); + \Tester\Assert::true($array['nested']['ignore_unmapped']); } - public function testAddQueryToNested(): void + public function testToArrayWithInnerHits(): void { - $nested = new \Spameri\ElasticQuery\Query\Nested('items'); - $nested->getQuery()->addMustQuery( - new \Spameri\ElasticQuery\Query\Term('items.name', 'Widget'), - ); - $nested->getQuery()->addShouldQuery( - new \Spameri\ElasticQuery\Query\Range('items.price', 10, 100), + $nested = new \Spameri\ElasticQuery\Query\Nested( + path: 'comments', + innerHits: new \Spameri\ElasticQuery\Query\InnerHits(name: 'matched_comments', size: 5), ); $array = $nested->toArray(); - \Tester\Assert::true(isset($array['nested']['query'][0]['bool']['must'])); - \Tester\Assert::true(isset($array['nested']['query'][0]['bool']['should'])); + \Tester\Assert::same('matched_comments', $array['nested']['inner_hits']['name']); + \Tester\Assert::same(5, $array['nested']['inner_hits']['size']); } - public function testKey(): void + public function testGetQuery(): void { - $nested = new \Spameri\ElasticQuery\Query\Nested('reviews'); + $nested = new \Spameri\ElasticQuery\Query\Nested('products'); - \Tester\Assert::same('nested_reviews', $nested->key()); + \Tester\Assert::type(\Spameri\ElasticQuery\Query\QueryCollection::class, $nested->getQuery()); } - public function testDeepNestedPath(): void + public function testKey(): void { - $nested = new \Spameri\ElasticQuery\Query\Nested('products.variants.sizes'); - - $array = $nested->toArray(); + $nested = new \Spameri\ElasticQuery\Query\Nested('reviews'); - \Tester\Assert::same('products.variants.sizes', $array['nested']['path']); + \Tester\Assert::same('nested_reviews', $nested->key()); } - public function testNestedInElasticQuery(): void + public function testCreate(): void { - $nested = new \Spameri\ElasticQuery\Query\Nested('comments'); - $nested->getQuery()->addMustQuery( + $this->indexDocument([ + 'comments' => [ + ['author' => 'John', 'text' => 'great'], + ['author' => 'Jane', 'text' => 'bad'], + ], + ]); + + $queryCollection = new \Spameri\ElasticQuery\Query\QueryCollection(); + $queryCollection->addMustQuery( new \Spameri\ElasticQuery\Query\Term('comments.author', 'John'), ); - $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery( + $query = new \Spameri\ElasticQuery\ElasticQuery( new \Spameri\ElasticQuery\Query\QueryCollection( null, new \Spameri\ElasticQuery\Query\MustCollection( - $nested, + new \Spameri\ElasticQuery\Query\Nested('comments', $queryCollection), ), ), ); - $array = $elasticQuery->toArray(); - - \Tester\Assert::true(isset($array['query']['bool']['must'])); - \Tester\Assert::count(1, $array['query']['bool']['must']); - \Tester\Assert::true(isset($array['query']['bool']['must'][0]['nested'])); - \Tester\Assert::same('comments', $array['query']['bool']['must'][0]['nested']['path']); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt b/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt index 806797d..8ec37ea 100644 --- a/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt @@ -35,7 +35,7 @@ class PhrasePrefix extends \Tester\TestCase \Tester\Assert::true(isset($array['match_phrase_prefix'])); \Tester\Assert::true(isset($array['match_phrase_prefix']['title'])); \Tester\Assert::same('quick brown f', $array['match_phrase_prefix']['title']['query']); - \Tester\Assert::same(1, $array['match_phrase_prefix']['title']['boost']); + \Tester\Assert::same(1.0, $array['match_phrase_prefix']['title']['boost']); \Tester\Assert::same(1, $array['match_phrase_prefix']['title']['slop']); } @@ -45,14 +45,14 @@ class PhrasePrefix extends \Tester\TestCase $phrasePrefix = new \Spameri\ElasticQuery\Query\PhrasePrefix( 'description', 'search phrase', - 2, + 2.0, 3, ); $array = $phrasePrefix->toArray(); \Tester\Assert::same('search phrase', $array['match_phrase_prefix']['description']['query']); - \Tester\Assert::same(2, $array['match_phrase_prefix']['description']['boost']); + \Tester\Assert::same(2.0, $array['match_phrase_prefix']['description']['boost']); \Tester\Assert::same(3, $array['match_phrase_prefix']['description']['slop']); } From 467d6bf2e1cb0063dd7edb8b8aef8c9b99060598 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 16:42:03 +0200 Subject: [PATCH 82/97] feat(query): bring full-text queries to ES DSL parity Eight full-text query types gained the constructor args that were missing against the current Elasticsearch reference: - ElasticMatch: zero_terms_query, auto_generate_synonyms_phrase_query, lenient, prefix_length, max_expansions, fuzzy_transpositions, fuzzy_rewrite - MultiMatch: tie_breaker, slop, prefix_length, max_expansions, lenient, zero_terms_query, auto_generate_synonyms_phrase_query, fuzzy_transpositions, fuzzy_rewrite - MatchPhrase: zero_terms_query - PhrasePrefix: analyzer, max_expansions, zero_terms_query - MatchBoolPrefix: fuzziness, prefix_length, max_expansions, fuzzy_transpositions, fuzzy_rewrite - QueryString: 17 new args including fuzziness, lenient, type, tie_breaker, rewrite, time_zone, minimum_should_match - SimpleQueryString: 8 new args including lenient, fuzzy_*, quote_field_suffix - CombinedFields: auto_generate_synonyms_phrase_query Tests migrated to AbstractElasticTestCase and each carries a testCreateWithAllOptions integration test that round-trips the fully-loaded query against the ES container. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Query/CombinedFields.php | 5 + src/Query/ElasticMatch.php | 65 ++++++-- src/Query/MatchBoolPrefix.php | 25 +++ src/Query/MatchPhrase.php | 5 + src/Query/MultiMatch.php | 80 +++++++-- src/Query/PhrasePrefix.php | 34 +++- src/Query/QueryString.php | 86 ++++++++++ src/Query/SimpleQueryString.php | 41 +++++ .../ElasticQuery/Query/CombinedFields.phpt | 55 ++++--- .../ElasticQuery/Query/ElasticMatch.phpt | 153 ++++++++---------- .../ElasticQuery/Query/MatchBoolPrefix.phpt | 103 ++++++------ .../ElasticQuery/Query/MatchPhrase.phpt | 108 ++++++------- .../ElasticQuery/Query/MultiMatch.phpt | 79 +++++++++ .../ElasticQuery/Query/PhrasePrefix.phpt | 114 ++++++------- .../ElasticQuery/Query/QueryString.phpt | 114 ++++++++++--- .../ElasticQuery/Query/SimpleQueryString.phpt | 96 ++++++++--- 16 files changed, 797 insertions(+), 366 deletions(-) diff --git a/src/Query/CombinedFields.php b/src/Query/CombinedFields.php index 4de2931..96238ad 100644 --- a/src/Query/CombinedFields.php +++ b/src/Query/CombinedFields.php @@ -20,6 +20,7 @@ public function __construct( private string|null $operator = null, private int|string|null $minimumShouldMatch = null, private string|null $zeroTermsQuery = null, + private bool|null $autoGenerateSynonymsPhraseQuery = null, ) { if ($fields === []) { @@ -59,6 +60,10 @@ public function toArray(): array $body['zero_terms_query'] = $this->zeroTermsQuery; } + if ($this->autoGenerateSynonymsPhraseQuery !== null) { + $body['auto_generate_synonyms_phrase_query'] = $this->autoGenerateSynonymsPhraseQuery; + } + return [ 'combined_fields' => $body, ]; diff --git a/src/Query/ElasticMatch.php b/src/Query/ElasticMatch.php index 185d47c..accea3d 100644 --- a/src/Query/ElasticMatch.php +++ b/src/Query/ElasticMatch.php @@ -19,6 +19,13 @@ public function __construct( private int|string|null $minimumShouldMatch = null, private string $operator = \Spameri\ElasticQuery\Query\Match\Operator::OR, private string|null $analyzer = null, + private string|null $zeroTermsQuery = null, + private bool|null $autoGenerateSynonymsPhraseQuery = null, + private bool|null $lenient = null, + private int|null $prefixLength = null, + private int|null $maxExpansions = null, + private bool|null $fuzzyTranspositions = null, + private string|null $fuzzyRewrite = null, ) { if ( ! \in_array($operator, \Spameri\ElasticQuery\Query\Match\Operator::OPERATORS, true)) { @@ -42,34 +49,62 @@ public function key(): string } + /** + * @return array>> + */ public function toArray(): array { - $array = [ - 'match' => [ - $this->field => [ - 'query' => $this->query, - 'boost' => $this->boost, - ], - ], + $body = [ + 'query' => $this->query, + 'boost' => $this->boost, + 'operator' => $this->operator, ]; - if ($this->operator) { - $array['match'][$this->field]['operator'] = $this->operator; - } - if ($this->fuzziness !== null) { - $array['match'][$this->field]['fuzziness'] = $this->fuzziness->__toString(); + $body['fuzziness'] = $this->fuzziness->__toString(); } if ($this->analyzer !== null) { - $array['match'][$this->field]['analyzer'] = $this->analyzer; + $body['analyzer'] = $this->analyzer; } if ($this->minimumShouldMatch !== null) { - $array['match'][$this->field]['minimum_should_match'] = $this->minimumShouldMatch; + $body['minimum_should_match'] = $this->minimumShouldMatch; + } + + if ($this->zeroTermsQuery !== null) { + $body['zero_terms_query'] = $this->zeroTermsQuery; } - return $array; + if ($this->autoGenerateSynonymsPhraseQuery !== null) { + $body['auto_generate_synonyms_phrase_query'] = $this->autoGenerateSynonymsPhraseQuery; + } + + if ($this->lenient !== null) { + $body['lenient'] = $this->lenient; + } + + if ($this->prefixLength !== null) { + $body['prefix_length'] = $this->prefixLength; + } + + if ($this->maxExpansions !== null) { + $body['max_expansions'] = $this->maxExpansions; + } + + if ($this->fuzzyTranspositions !== null) { + $body['fuzzy_transpositions'] = $this->fuzzyTranspositions; + } + + if ($this->fuzzyRewrite !== null) { + $body['fuzzy_rewrite'] = $this->fuzzyRewrite; + } + + return [ + 'match' => [ + $this->field => $body, + ], + ]; } } diff --git a/src/Query/MatchBoolPrefix.php b/src/Query/MatchBoolPrefix.php index 7179906..23953ee 100644 --- a/src/Query/MatchBoolPrefix.php +++ b/src/Query/MatchBoolPrefix.php @@ -17,6 +17,11 @@ public function __construct( private string|null $operator = null, private int|string|null $minimumShouldMatch = null, private string|null $analyzer = null, + private \Spameri\ElasticQuery\Query\Match\Fuzziness|null $fuzziness = null, + private int|null $prefixLength = null, + private int|null $maxExpansions = null, + private bool|null $fuzzyTranspositions = null, + private string|null $fuzzyRewrite = null, ) { } @@ -50,6 +55,26 @@ public function toArray(): array $body['analyzer'] = $this->analyzer; } + if ($this->fuzziness !== null) { + $body['fuzziness'] = $this->fuzziness->__toString(); + } + + if ($this->prefixLength !== null) { + $body['prefix_length'] = $this->prefixLength; + } + + if ($this->maxExpansions !== null) { + $body['max_expansions'] = $this->maxExpansions; + } + + if ($this->fuzzyTranspositions !== null) { + $body['fuzzy_transpositions'] = $this->fuzzyTranspositions; + } + + if ($this->fuzzyRewrite !== null) { + $body['fuzzy_rewrite'] = $this->fuzzyRewrite; + } + return [ 'match_bool_prefix' => [ $this->field => $body, diff --git a/src/Query/MatchPhrase.php b/src/Query/MatchPhrase.php index 71f5ccd..4552176 100644 --- a/src/Query/MatchPhrase.php +++ b/src/Query/MatchPhrase.php @@ -17,6 +17,7 @@ public function __construct( private float $boost = 1.0, private int $slop = 0, private string|null $analyzer = null, + private string|null $zeroTermsQuery = null, ) { } @@ -53,6 +54,10 @@ public function toArray(): array $array['match_phrase'][$this->field]['slop'] = $this->slop; } + if ($this->zeroTermsQuery !== null) { + $array['match_phrase'][$this->field]['zero_terms_query'] = $this->zeroTermsQuery; + } + return $array; } diff --git a/src/Query/MultiMatch.php b/src/Query/MultiMatch.php index 882a7e0..7177c7a 100644 --- a/src/Query/MultiMatch.php +++ b/src/Query/MultiMatch.php @@ -11,6 +11,9 @@ class MultiMatch implements LeafQueryInterface { + /** + * @param array $fields + */ public function __construct( private array $fields, private bool|int|string|null $query, @@ -20,6 +23,15 @@ public function __construct( private int|string|null $minimumShouldMatch = null, private string $operator = \Spameri\ElasticQuery\Query\Match\Operator::OR, private string|null $analyzer = null, + private float|null $tieBreaker = null, + private int|null $slop = null, + private int|null $prefixLength = null, + private int|null $maxExpansions = null, + private bool|null $lenient = null, + private string|null $zeroTermsQuery = null, + private bool|null $autoGenerateSynonymsPhraseQuery = null, + private bool|null $fuzzyTranspositions = null, + private string|null $fuzzyRewrite = null, ) { if ( ! \in_array($operator, \Spameri\ElasticQuery\Query\Match\Operator::OPERATORS, true)) { @@ -48,34 +60,70 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { - $array = [ - 'multi_match' => [ - 'query' => $this->query, - 'type' => $this->type, - 'fields' => $this->fields, - 'boost' => $this->boost, - ], + $body = [ + 'query' => $this->query, + 'type' => $this->type, + 'fields' => $this->fields, + 'boost' => $this->boost, + 'operator' => $this->operator, ]; - if ($this->operator) { - $array['multi_match']['operator'] = $this->operator; + if ($this->fuzziness !== null && $this->fuzziness->__toString() !== '') { + $body['fuzziness'] = $this->fuzziness->__toString(); } - if ($this->fuzziness && $this->fuzziness->__toString()) { - $array['multi_match']['fuzziness'] = $this->fuzziness->__toString(); + if ($this->analyzer !== null) { + $body['analyzer'] = $this->analyzer; } - if ($this->analyzer) { - $array['multi_match']['analyzer'] = $this->analyzer; + if ($this->minimumShouldMatch !== null) { + $body['minimum_should_match'] = $this->minimumShouldMatch; } - if ($this->minimumShouldMatch) { - $array['multi_match']['minimum_should_match'] = $this->minimumShouldMatch; + if ($this->tieBreaker !== null) { + $body['tie_breaker'] = $this->tieBreaker; } - return $array; + if ($this->slop !== null) { + $body['slop'] = $this->slop; + } + + if ($this->prefixLength !== null) { + $body['prefix_length'] = $this->prefixLength; + } + + if ($this->maxExpansions !== null) { + $body['max_expansions'] = $this->maxExpansions; + } + + if ($this->lenient !== null) { + $body['lenient'] = $this->lenient; + } + + if ($this->zeroTermsQuery !== null) { + $body['zero_terms_query'] = $this->zeroTermsQuery; + } + + if ($this->autoGenerateSynonymsPhraseQuery !== null) { + $body['auto_generate_synonyms_phrase_query'] = $this->autoGenerateSynonymsPhraseQuery; + } + + if ($this->fuzzyTranspositions !== null) { + $body['fuzzy_transpositions'] = $this->fuzzyTranspositions; + } + + if ($this->fuzzyRewrite !== null) { + $body['fuzzy_rewrite'] = $this->fuzzyRewrite; + } + + return [ + 'multi_match' => $body, + ]; } } diff --git a/src/Query/PhrasePrefix.php b/src/Query/PhrasePrefix.php index 56b824e..413e7d0 100644 --- a/src/Query/PhrasePrefix.php +++ b/src/Query/PhrasePrefix.php @@ -4,6 +4,10 @@ namespace Spameri\ElasticQuery\Query; + +/** + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query-phrase-prefix.html + */ class PhrasePrefix implements \Spameri\ElasticQuery\Query\LeafQueryInterface { @@ -12,6 +16,9 @@ public function __construct( private string $queryString, private float $boost = 1.0, private int $slop = 1, + private string|null $analyzer = null, + private int|null $maxExpansions = null, + private string|null $zeroTermsQuery = null, ) { } @@ -23,15 +30,32 @@ public function key(): string } + /** + * @return array>> + */ public function toArray(): array { + $body = [ + 'query' => $this->queryString, + 'boost' => $this->boost, + 'slop' => $this->slop, + ]; + + if ($this->analyzer !== null) { + $body['analyzer'] = $this->analyzer; + } + + if ($this->maxExpansions !== null) { + $body['max_expansions'] = $this->maxExpansions; + } + + if ($this->zeroTermsQuery !== null) { + $body['zero_terms_query'] = $this->zeroTermsQuery; + } + return [ 'match_phrase_prefix' => [ - $this->field => [ - 'query' => $this->queryString, - 'boost' => $this->boost, - 'slop' => $this->slop, - ], + $this->field => $body, ], ]; } diff --git a/src/Query/QueryString.php b/src/Query/QueryString.php index b4405bb..5e3522d 100644 --- a/src/Query/QueryString.php +++ b/src/Query/QueryString.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html */ @@ -21,6 +22,23 @@ public function __construct( private string|null $analyzer = null, private bool|null $allowLeadingWildcard = null, private float $boost = 1.0, + private bool|null $analyzeWildcard = null, + private bool|null $autoGenerateSynonymsPhraseQuery = null, + private bool|null $enablePositionIncrements = null, + private \Spameri\ElasticQuery\Query\Match\Fuzziness|null $fuzziness = null, + private int|null $fuzzyMaxExpansions = null, + private int|null $fuzzyPrefixLength = null, + private bool|null $fuzzyTranspositions = null, + private bool|null $lenient = null, + private int|null $maxDeterminizedStates = null, + private int|string|null $minimumShouldMatch = null, + private string|null $quoteAnalyzer = null, + private int|null $phraseSlop = null, + private string|null $quoteFieldSuffix = null, + private string|null $rewrite = null, + private string|null $timeZone = null, + private string|null $type = null, + private float|null $tieBreaker = null, ) { } @@ -62,6 +80,74 @@ public function toArray(): array $body['allow_leading_wildcard'] = $this->allowLeadingWildcard; } + if ($this->analyzeWildcard !== null) { + $body['analyze_wildcard'] = $this->analyzeWildcard; + } + + if ($this->autoGenerateSynonymsPhraseQuery !== null) { + $body['auto_generate_synonyms_phrase_query'] = $this->autoGenerateSynonymsPhraseQuery; + } + + if ($this->enablePositionIncrements !== null) { + $body['enable_position_increments'] = $this->enablePositionIncrements; + } + + if ($this->fuzziness !== null) { + $body['fuzziness'] = $this->fuzziness->__toString(); + } + + if ($this->fuzzyMaxExpansions !== null) { + $body['fuzzy_max_expansions'] = $this->fuzzyMaxExpansions; + } + + if ($this->fuzzyPrefixLength !== null) { + $body['fuzzy_prefix_length'] = $this->fuzzyPrefixLength; + } + + if ($this->fuzzyTranspositions !== null) { + $body['fuzzy_transpositions'] = $this->fuzzyTranspositions; + } + + if ($this->lenient !== null) { + $body['lenient'] = $this->lenient; + } + + if ($this->maxDeterminizedStates !== null) { + $body['max_determinized_states'] = $this->maxDeterminizedStates; + } + + if ($this->minimumShouldMatch !== null) { + $body['minimum_should_match'] = $this->minimumShouldMatch; + } + + if ($this->quoteAnalyzer !== null) { + $body['quote_analyzer'] = $this->quoteAnalyzer; + } + + if ($this->phraseSlop !== null) { + $body['phrase_slop'] = $this->phraseSlop; + } + + if ($this->quoteFieldSuffix !== null) { + $body['quote_field_suffix'] = $this->quoteFieldSuffix; + } + + if ($this->rewrite !== null) { + $body['rewrite'] = $this->rewrite; + } + + if ($this->timeZone !== null) { + $body['time_zone'] = $this->timeZone; + } + + if ($this->type !== null) { + $body['type'] = $this->type; + } + + if ($this->tieBreaker !== null) { + $body['tie_breaker'] = $this->tieBreaker; + } + return [ 'query_string' => $body, ]; diff --git a/src/Query/SimpleQueryString.php b/src/Query/SimpleQueryString.php index 5d54748..8876dab 100644 --- a/src/Query/SimpleQueryString.php +++ b/src/Query/SimpleQueryString.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html */ @@ -20,6 +21,14 @@ public function __construct( private string|null $analyzer = null, private string|null $flags = null, private float $boost = 1.0, + private bool|null $analyzeWildcard = null, + private bool|null $autoGenerateSynonymsPhraseQuery = null, + private int|null $fuzzyMaxExpansions = null, + private int|null $fuzzyPrefixLength = null, + private bool|null $fuzzyTranspositions = null, + private bool|null $lenient = null, + private int|string|null $minimumShouldMatch = null, + private string|null $quoteFieldSuffix = null, ) { } @@ -57,6 +66,38 @@ public function toArray(): array $body['flags'] = $this->flags; } + if ($this->analyzeWildcard !== null) { + $body['analyze_wildcard'] = $this->analyzeWildcard; + } + + if ($this->autoGenerateSynonymsPhraseQuery !== null) { + $body['auto_generate_synonyms_phrase_query'] = $this->autoGenerateSynonymsPhraseQuery; + } + + if ($this->fuzzyMaxExpansions !== null) { + $body['fuzzy_max_expansions'] = $this->fuzzyMaxExpansions; + } + + if ($this->fuzzyPrefixLength !== null) { + $body['fuzzy_prefix_length'] = $this->fuzzyPrefixLength; + } + + if ($this->fuzzyTranspositions !== null) { + $body['fuzzy_transpositions'] = $this->fuzzyTranspositions; + } + + if ($this->lenient !== null) { + $body['lenient'] = $this->lenient; + } + + if ($this->minimumShouldMatch !== null) { + $body['minimum_should_match'] = $this->minimumShouldMatch; + } + + if ($this->quoteFieldSuffix !== null) { + $body['quote_field_suffix'] = $this->quoteFieldSuffix; + } + return [ 'simple_query_string' => $body, ]; diff --git a/tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt b/tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt index 9a3f3e5..ac9fb78 100644 --- a/tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/CombinedFields.phpt @@ -5,22 +5,10 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class CombinedFields extends \Tester\TestCase +class CombinedFields extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_combined_fields'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_query_combined_fields'; public function testToArray(): void @@ -38,6 +26,18 @@ class CombinedFields extends \Tester\TestCase } + public function testAutoGenerateSynonyms(): void + { + $cf = new \Spameri\ElasticQuery\Query\CombinedFields( + fields: ['body'], + query: 'x', + autoGenerateSynonymsPhraseQuery: false, + ); + + \Tester\Assert::false($cf->toArray()['combined_fields']['auto_generate_synonyms_phrase_query']); + } + + public function testRequiresFields(): void { \Tester\Assert::exception( @@ -52,20 +52,31 @@ class CombinedFields extends \Tester\TestCase public function testKey(): void { $cf = new \Spameri\ElasticQuery\Query\CombinedFields(['title', 'body'], 'q'); - \Tester\Assert::same('combined_fields_title-body_q', $cf->key()); } - public function tearDown(): void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['title' => 'distributed search', 'body' => 'engine']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\CombinedFields( + fields: ['title', 'body'], + query: 'distributed search', + operator: 'and', + autoGenerateSynonymsPhraseQuery: true, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/ElasticMatch.phpt b/tests/SpameriTests/ElasticQuery/Query/ElasticMatch.phpt index 69198fe..8ebe3fc 100644 --- a/tests/SpameriTests/ElasticQuery/Query/ElasticMatch.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/ElasticMatch.phpt @@ -5,141 +5,116 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class ElasticMatch extends \Tester\TestCase +class ElasticMatch extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_match'; + protected const INDEX = 'spameri_test_query_match'; - public function setUp() : void - { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } - - - public function testCreate() : void + public function testToArray(): void { $match = new \Spameri\ElasticQuery\Query\ElasticMatch( 'name', 'Avengers', 1.0, - new \Spameri\ElasticQuery\Query\Match\Fuzziness( - \Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO - ), + new \Spameri\ElasticQuery\Query\Match\Fuzziness(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO), 2, \Spameri\ElasticQuery\Query\Match\Operator::OR, - 'standard' + 'standard', ); $array = $match->toArray(); - \Tester\Assert::true(isset($array['match']['name']['query'])); \Tester\Assert::same('Avengers', $array['match']['name']['query']); \Tester\Assert::same(1.0, $array['match']['name']['boost']); \Tester\Assert::same(\Spameri\ElasticQuery\Query\Match\Operator::OR, $array['match']['name']['operator']); \Tester\Assert::same(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO, $array['match']['name']['fuzziness']); \Tester\Assert::same('standard', $array['match']['name']['analyzer']); \Tester\Assert::same(2, $array['match']['name']['minimum_should_match']); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - NULL, - new \Spameri\ElasticQuery\Query\MustCollection( - $match - ) - ) - ) - )->toArray() - ) - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']) - ); - - \Tester\Assert::noError(static function () use ($ch) { - $response = curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, TRUE)); - \Tester\Assert::type('int', $result->stats()->total()); - }); } - public function testMinimumShouldMatchString() : void + public function testToArrayWithAllNewOptions(): void { $match = new \Spameri\ElasticQuery\Query\ElasticMatch( - 'name', - 'Avengers Endgame', - 1.0, - null, - '75%', + field: 'name', + query: 'Avengers', + zeroTermsQuery: 'all', + autoGenerateSynonymsPhraseQuery: false, + lenient: true, + prefixLength: 1, + maxExpansions: 50, + fuzzyTranspositions: false, + fuzzyRewrite: 'constant_score', ); $array = $match->toArray(); - \Tester\Assert::same('75%', $array['match']['name']['minimum_should_match']); + \Tester\Assert::same('all', $array['match']['name']['zero_terms_query']); + \Tester\Assert::false($array['match']['name']['auto_generate_synonyms_phrase_query']); + \Tester\Assert::true($array['match']['name']['lenient']); + \Tester\Assert::same(1, $array['match']['name']['prefix_length']); + \Tester\Assert::same(50, $array['match']['name']['max_expansions']); + \Tester\Assert::false($array['match']['name']['fuzzy_transpositions']); + \Tester\Assert::same('constant_score', $array['match']['name']['fuzzy_rewrite']); } - public function testMinimumShouldMatchCombinationString() : void + public function testMinimumShouldMatchString(): void { - $match = new \Spameri\ElasticQuery\Query\ElasticMatch( - 'name', - 'Avengers Endgame Infinity War', - 1.0, - null, - '2<90%', - ); - - $array = $match->toArray(); - - \Tester\Assert::same('2<90%', $array['match']['name']['minimum_should_match']); + $match = new \Spameri\ElasticQuery\Query\ElasticMatch('name', 'Avengers Endgame', 1.0, null, '75%'); + \Tester\Assert::same('75%', $match->toArray()['match']['name']['minimum_should_match']); } - public function testMinimumShouldMatchInt() : void + public function testCreate(): void { - $match = new \Spameri\ElasticQuery\Query\ElasticMatch( - 'name', - 'Avengers Endgame', - 1.0, - null, - 2, + $this->indexDocument(['name' => 'Avengers Endgame']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\ElasticMatch('name', 'Avengers'), + ), + ), ); - $array = $match->toArray(); + $result = $this->search($query); - \Tester\Assert::same(2, $array['match']['name']['minimum_should_match']); + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown() : void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['name' => 'Avengers Endgame Infinity']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\ElasticMatch( + field: 'name', + query: 'Avengers', + boost: 2.0, + fuzziness: new \Spameri\ElasticQuery\Query\Match\Fuzziness(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO), + operator: \Spameri\ElasticQuery\Query\Match\Operator::OR, + analyzer: 'standard', + zeroTermsQuery: 'none', + autoGenerateSynonymsPhraseQuery: true, + lenient: true, + prefixLength: 0, + maxExpansions: 50, + fuzzyTranspositions: true, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt b/tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt index 72e5b29..6474131 100644 --- a/tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/MatchBoolPrefix.phpt @@ -5,91 +5,98 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class MatchBoolPrefix extends \Tester\TestCase +class MatchBoolPrefix extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_match_bool_prefix'; + protected const INDEX = 'spameri_test_query_match_bool_prefix'; - public function setUp(): void + public function testToArray(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $match = new \Spameri\ElasticQuery\Query\MatchBoolPrefix( + field: 'message', + query: 'quick brown f', + ); - \curl_exec($ch); + \Tester\Assert::same('quick brown f', $match->toArray()['match_bool_prefix']['message']['query']); } - public function testToArray(): void + public function testToArrayWithAllOptions(): void { $match = new \Spameri\ElasticQuery\Query\MatchBoolPrefix( field: 'message', - query: 'quick brown f', + query: 'q', + boost: 1.5, + operator: 'or', + minimumShouldMatch: '50%', + analyzer: 'standard', + fuzziness: new \Spameri\ElasticQuery\Query\Match\Fuzziness(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO), + prefixLength: 0, + maxExpansions: 50, + fuzzyTranspositions: true, + fuzzyRewrite: 'constant_score', ); $array = $match->toArray(); - \Tester\Assert::same('quick brown f', $array['match_bool_prefix']['message']['query']); + \Tester\Assert::same(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO, $array['match_bool_prefix']['message']['fuzziness']); + \Tester\Assert::same(0, $array['match_bool_prefix']['message']['prefix_length']); + \Tester\Assert::same(50, $array['match_bool_prefix']['message']['max_expansions']); + \Tester\Assert::true($array['match_bool_prefix']['message']['fuzzy_transpositions']); + \Tester\Assert::same('constant_score', $array['match_bool_prefix']['message']['fuzzy_rewrite']); } public function testKey(): void { $match = new \Spameri\ElasticQuery\Query\MatchBoolPrefix('message', 'quick'); - \Tester\Assert::same('match_bool_prefix_message_quick', $match->key()); } public function testCreate(): void { - $match = new \Spameri\ElasticQuery\Query\MatchBoolPrefix('message', 'q'); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - (new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - null, - new \Spameri\ElasticQuery\Query\MustCollection($match), - ), - ))->toArray(), + $this->indexDocument(['message' => 'quick brown fox']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\MatchBoolPrefix('message', 'quick brown f'), + ), ), ); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); + $result = $this->search($query); - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown(): void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['message' => 'quick brown fox']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\MatchBoolPrefix( + field: 'message', + query: 'qiuck', + fuzziness: new \Spameri\ElasticQuery\Query\Match\Fuzziness(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO), + prefixLength: 0, + maxExpansions: 50, + fuzzyTranspositions: true, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::true($result->stats()->total() >= 0); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/MatchPhrase.phpt b/tests/SpameriTests/ElasticQuery/Query/MatchPhrase.phpt index e91013a..213c699 100644 --- a/tests/SpameriTests/ElasticQuery/Query/MatchPhrase.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/MatchPhrase.phpt @@ -5,87 +5,79 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class MatchPhrase extends \Tester\TestCase +class MatchPhrase extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_match_phrase'; + protected const INDEX = 'spameri_test_query_match_phrase'; - public function setUp() : void + public function testToArray(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $match = new \Spameri\ElasticQuery\Query\MatchPhrase('name', 'Avengers', 1.0, 1, 'standard'); - \curl_exec($ch); + $array = $match->toArray(); + + \Tester\Assert::same('Avengers', $array['match_phrase']['name']['query']); + \Tester\Assert::same(1.0, $array['match_phrase']['name']['boost']); + \Tester\Assert::same(1, $array['match_phrase']['name']['slop']); + \Tester\Assert::same('standard', $array['match_phrase']['name']['analyzer']); } - public function testCreate() : void + public function testZeroTermsQuery(): void { $match = new \Spameri\ElasticQuery\Query\MatchPhrase( - 'name', - 'Avengers', - 1.0, - 1, - 'standard' + field: 'name', + query: 'foo', + zeroTermsQuery: 'all', ); - $array = $match->toArray(); + \Tester\Assert::same('all', $match->toArray()['match_phrase']['name']['zero_terms_query']); + } - \Tester\Assert::true(isset($array['match_phrase']['name']['query'])); - \Tester\Assert::same('Avengers', $array['match_phrase']['name']['query']); - \Tester\Assert::same(1.0, $array['match_phrase']['name']['boost']); - \Tester\Assert::same(1, $array['match_phrase']['name']['slop']); - \Tester\Assert::same('standard', $array['match_phrase']['name']['analyzer']); - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - NULL, - new \Spameri\ElasticQuery\Query\MustCollection( - $match - ) - ) - ) - )->toArray() - ) + public function testCreate(): void + { + $this->indexDocument(['name' => 'Avengers Endgame']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\MatchPhrase('name', 'Avengers Endgame'), + ), + ), ); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']) - ); + $result = $this->search($query); - \Tester\Assert::noError(static function () use ($ch) { - $response = curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, TRUE)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown() : void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['name' => 'Avengers Endgame']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\MatchPhrase( + field: 'name', + query: 'Avengers Endgame', + boost: 1.5, + slop: 1, + analyzer: 'standard', + zeroTermsQuery: 'none', + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/MultiMatch.phpt b/tests/SpameriTests/ElasticQuery/Query/MultiMatch.phpt index 91f2ef0..f08203b 100644 --- a/tests/SpameriTests/ElasticQuery/Query/MultiMatch.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/MultiMatch.phpt @@ -205,6 +205,36 @@ class MultiMatch extends \Tester\TestCase } + public function testToArrayWithNewOptions(): void + { + $multiMatch = new \Spameri\ElasticQuery\Query\MultiMatch( + fields: ['title'], + query: 'test', + tieBreaker: 0.3, + slop: 2, + prefixLength: 1, + maxExpansions: 50, + lenient: true, + zeroTermsQuery: 'all', + autoGenerateSynonymsPhraseQuery: false, + fuzzyTranspositions: false, + fuzzyRewrite: 'constant_score', + ); + + $array = $multiMatch->toArray(); + + \Tester\Assert::same(0.3, $array['multi_match']['tie_breaker']); + \Tester\Assert::same(2, $array['multi_match']['slop']); + \Tester\Assert::same(1, $array['multi_match']['prefix_length']); + \Tester\Assert::same(50, $array['multi_match']['max_expansions']); + \Tester\Assert::true($array['multi_match']['lenient']); + \Tester\Assert::same('all', $array['multi_match']['zero_terms_query']); + \Tester\Assert::false($array['multi_match']['auto_generate_synonyms_phrase_query']); + \Tester\Assert::false($array['multi_match']['fuzzy_transpositions']); + \Tester\Assert::same('constant_score', $array['multi_match']['fuzzy_rewrite']); + } + + public function testCreate(): void { $multiMatch = new \Spameri\ElasticQuery\Query\MultiMatch( @@ -249,6 +279,55 @@ class MultiMatch extends \Tester\TestCase } + public function testCreateWithAllOptions(): void + { + $multiMatch = new \Spameri\ElasticQuery\Query\MultiMatch( + fields: ['title', 'description'], + query: 'Avengers', + boost: 2.0, + fuzziness: new \Spameri\ElasticQuery\Query\Match\Fuzziness(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO), + type: \Spameri\ElasticQuery\Query\Match\MultiMatchType::BEST_FIELDS, + minimumShouldMatch: 1, + operator: \Spameri\ElasticQuery\Query\Match\Operator::OR, + analyzer: 'standard', + tieBreaker: 0.3, + slop: 2, + prefixLength: 0, + maxExpansions: 50, + lenient: true, + zeroTermsQuery: 'none', + autoGenerateSynonymsPhraseQuery: true, + fuzzyTranspositions: true, + ); + + $document = new \Spameri\ElasticQuery\Document( + self::INDEX, + new \Spameri\ElasticQuery\Document\Body\Plain( + ( + new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection($multiMatch), + ), + ) + )->toArray(), + ), + ); + + $ch = \curl_init(); + \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); + \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); + \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'POST'); + \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + \curl_setopt($ch, \CURLOPT_POSTFIELDS, (string) \json_encode($document->toArray()['body'])); + + $response = (string) \curl_exec($ch); + $decoded = \json_decode($response, true); + + \Tester\Assert::true(isset($decoded['hits']), 'ES rejected: ' . $response); + } + + public function tearDown(): void { $ch = \curl_init(); diff --git a/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt b/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt index 8ec37ea..d0ccba2 100644 --- a/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/PhrasePrefix.phpt @@ -5,22 +5,10 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class PhrasePrefix extends \Tester\TestCase +class PhrasePrefix extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_phrase_prefix'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_query_phrase_prefix'; public function testToArrayBasic(): void @@ -32,95 +20,83 @@ class PhrasePrefix extends \Tester\TestCase $array = $phrasePrefix->toArray(); - \Tester\Assert::true(isset($array['match_phrase_prefix'])); - \Tester\Assert::true(isset($array['match_phrase_prefix']['title'])); \Tester\Assert::same('quick brown f', $array['match_phrase_prefix']['title']['query']); \Tester\Assert::same(1.0, $array['match_phrase_prefix']['title']['boost']); \Tester\Assert::same(1, $array['match_phrase_prefix']['title']['slop']); } - public function testToArrayWithCustomBoostAndSlop(): void + public function testToArrayWithAllOptions(): void { $phrasePrefix = new \Spameri\ElasticQuery\Query\PhrasePrefix( - 'description', - 'search phrase', - 2.0, - 3, + field: 'description', + queryString: 'search phrase', + boost: 2.0, + slop: 3, + analyzer: 'standard', + maxExpansions: 50, + zeroTermsQuery: 'none', ); $array = $phrasePrefix->toArray(); - \Tester\Assert::same('search phrase', $array['match_phrase_prefix']['description']['query']); \Tester\Assert::same(2.0, $array['match_phrase_prefix']['description']['boost']); \Tester\Assert::same(3, $array['match_phrase_prefix']['description']['slop']); + \Tester\Assert::same('standard', $array['match_phrase_prefix']['description']['analyzer']); + \Tester\Assert::same(50, $array['match_phrase_prefix']['description']['max_expansions']); + \Tester\Assert::same('none', $array['match_phrase_prefix']['description']['zero_terms_query']); } public function testKey(): void { - $phrasePrefix = new \Spameri\ElasticQuery\Query\PhrasePrefix( - 'title', - 'test query', - ); - + $phrasePrefix = new \Spameri\ElasticQuery\Query\PhrasePrefix('title', 'test query'); \Tester\Assert::same('phrase_prefix_title_test query', $phrasePrefix->key()); } public function testCreate(): void { - $phrasePrefix = new \Spameri\ElasticQuery\Query\PhrasePrefix( - 'title', - 'Aveng', - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - null, - new \Spameri\ElasticQuery\Query\MustCollection( - $phrasePrefix, - ), - ), - ) - )->toArray(), + $this->indexDocument(['title' => 'Avengers Endgame']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\PhrasePrefix('title', 'Aveng'), + ), ), ); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); + $result = $this->search($query); - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown(): void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['title' => 'Avengers Endgame']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\PhrasePrefix( + field: 'title', + queryString: 'Aveng', + boost: 1.5, + slop: 1, + analyzer: 'standard', + maxExpansions: 50, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/QueryString.phpt b/tests/SpameriTests/ElasticQuery/Query/QueryString.phpt index 5b32c29..76ba8d2 100644 --- a/tests/SpameriTests/ElasticQuery/Query/QueryString.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/QueryString.phpt @@ -5,22 +5,10 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class QueryString extends \Tester\TestCase +class QueryString extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_query_string'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_query_query_string'; public function testToArray(): void @@ -37,23 +25,105 @@ class QueryString extends \Tester\TestCase } + public function testToArrayWithAllOptions(): void + { + $qs = new \Spameri\ElasticQuery\Query\QueryString( + query: 'foo', + fields: ['title^2', 'body'], + defaultOperator: 'AND', + analyzer: 'standard', + allowLeadingWildcard: true, + analyzeWildcard: true, + autoGenerateSynonymsPhraseQuery: false, + enablePositionIncrements: true, + fuzziness: new \Spameri\ElasticQuery\Query\Match\Fuzziness(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO), + fuzzyMaxExpansions: 50, + fuzzyPrefixLength: 0, + fuzzyTranspositions: true, + lenient: true, + maxDeterminizedStates: 10000, + minimumShouldMatch: '50%', + quoteAnalyzer: 'standard', + phraseSlop: 0, + quoteFieldSuffix: '.exact', + rewrite: 'constant_score', + timeZone: 'UTC', + type: 'best_fields', + tieBreaker: 0.3, + ); + + $array = $qs->toArray(); + + \Tester\Assert::true($array['query_string']['analyze_wildcard']); + \Tester\Assert::false($array['query_string']['auto_generate_synonyms_phrase_query']); + \Tester\Assert::same(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO, $array['query_string']['fuzziness']); + \Tester\Assert::same(50, $array['query_string']['fuzzy_max_expansions']); + \Tester\Assert::same('best_fields', $array['query_string']['type']); + \Tester\Assert::same(0.3, $array['query_string']['tie_breaker']); + } + + public function testKey(): void { $qs = new \Spameri\ElasticQuery\Query\QueryString('foo'); - \Tester\Assert::same('query_string_foo', $qs->key()); } - public function tearDown(): void + public function testCreate(): void + { + $this->indexDocument(['content' => 'hello world']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\QueryString(query: 'hello', defaultField: 'content'), + ), + ), + ); + + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); + } + + + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['content' => 'hello world']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\QueryString( + query: 'hello*', + defaultField: 'content', + defaultOperator: 'AND', + analyzer: 'standard', + allowLeadingWildcard: false, + boost: 1.0, + analyzeWildcard: true, + autoGenerateSynonymsPhraseQuery: true, + enablePositionIncrements: true, + fuzziness: new \Spameri\ElasticQuery\Query\Match\Fuzziness(\Spameri\ElasticQuery\Query\Match\Fuzziness::AUTO), + fuzzyMaxExpansions: 50, + fuzzyPrefixLength: 0, + fuzzyTranspositions: true, + lenient: true, + maxDeterminizedStates: 10000, + phraseSlop: 0, + type: 'best_fields', + tieBreaker: 0.3, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt b/tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt index 154a0a0..3fa2fd6 100644 --- a/tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/SimpleQueryString.phpt @@ -5,22 +5,10 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class SimpleQueryString extends \Tester\TestCase +class SimpleQueryString extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_simple_query_string'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_query_simple_query_string'; public function testToArray(): void @@ -37,23 +25,87 @@ class SimpleQueryString extends \Tester\TestCase } + public function testToArrayWithAllOptions(): void + { + $sqs = new \Spameri\ElasticQuery\Query\SimpleQueryString( + query: 'foo', + fields: ['body'], + defaultOperator: 'AND', + analyzer: 'standard', + flags: 'ALL', + analyzeWildcard: true, + autoGenerateSynonymsPhraseQuery: false, + fuzzyMaxExpansions: 50, + fuzzyPrefixLength: 0, + fuzzyTranspositions: true, + lenient: true, + minimumShouldMatch: '50%', + quoteFieldSuffix: '.exact', + ); + + $array = $sqs->toArray(); + + \Tester\Assert::true($array['simple_query_string']['analyze_wildcard']); + \Tester\Assert::false($array['simple_query_string']['auto_generate_synonyms_phrase_query']); + \Tester\Assert::same(50, $array['simple_query_string']['fuzzy_max_expansions']); + \Tester\Assert::same('.exact', $array['simple_query_string']['quote_field_suffix']); + } + + public function testKey(): void { $sqs = new \Spameri\ElasticQuery\Query\SimpleQueryString('q'); - \Tester\Assert::same('simple_query_string_q', $sqs->key()); } - public function tearDown(): void + public function testCreate(): void + { + $this->indexDocument(['body' => 'hello world']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\SimpleQueryString(query: 'hello', fields: ['body']), + ), + ), + ); + + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); + } + + + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['body' => 'hello world']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\SimpleQueryString( + query: 'hello', + fields: ['body'], + defaultOperator: 'AND', + analyzer: 'standard', + flags: 'ALL', + analyzeWildcard: true, + autoGenerateSynonymsPhraseQuery: true, + fuzzyMaxExpansions: 50, + fuzzyPrefixLength: 0, + fuzzyTranspositions: true, + lenient: true, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } From 3f06817ed2b9aafbb4ee30502a08a7b41ec65cf1 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 16:56:20 +0200 Subject: [PATCH 83/97] feat(query): bring term-level queries to ES DSL parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Term: case_insensitive - Terms: terms_lookup (new TermsLookup sub-object — cross-document values fetched from {index, id, path, routing}) - Range: gt, lt (strict bounds), format, relation (new Relation constants class with INTERSECTS/CONTAINS/WITHIN), time_zone - Exists: boost - WildCard: case_insensitive, rewrite - Prefix: rewrite (case_insensitive was already there) - Fuzzy: transpositions, rewrite - Regexp: rewrite - TermSet: boost Tests migrated to AbstractElasticTestCase. Each new arg is exercised end-to-end via a testCreateWithAllOptions round-trip. Terms gains testCreateWithLookup that indexes a lookup document in a separate index and verifies the query resolves its values. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Query/Exists.php | 5 + src/Query/Fuzzy.php | 34 ++-- src/Query/Prefix.php | 5 + src/Query/Range.php | 97 +++++++----- src/Query/Range/Relation.php | 24 +++ src/Query/Regexp.php | 5 + src/Query/Term.php | 19 ++- src/Query/TermSet.php | 2 + src/Query/Terms.php | 25 ++- src/Query/TermsLookup.php | 44 ++++++ src/Query/WildCard.php | 24 ++- .../ElasticQuery/Query/Exists.phpt | 93 ++--------- .../ElasticQuery/Query/Fuzzy.phpt | 115 +++++++------- .../ElasticQuery/Query/Prefix.phpt | 88 +++++------ .../ElasticQuery/Query/Range.phpt | 147 +++++++++++------- .../ElasticQuery/Query/Regexp.phpt | 96 ++++++------ .../ElasticQuery/Query/TermSet.phpt | 59 +++---- .../ElasticQuery/Query/Terms.phpt | 123 ++++++++------- .../ElasticQuery/Query/WildCard.phpt | 110 +++++++------ 19 files changed, 629 insertions(+), 486 deletions(-) create mode 100644 src/Query/Range/Relation.php create mode 100644 src/Query/TermsLookup.php diff --git a/src/Query/Exists.php b/src/Query/Exists.php index 3b5d20d..46e224b 100644 --- a/src/Query/Exists.php +++ b/src/Query/Exists.php @@ -13,6 +13,7 @@ class Exists implements LeafQueryInterface public function __construct( private string $field, + private float $boost = 1.0, ) { } @@ -24,11 +25,15 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { return [ 'exists' => [ 'field' => $this->field, + 'boost' => $this->boost, ], ]; } diff --git a/src/Query/Fuzzy.php b/src/Query/Fuzzy.php index 4529c50..916ae2c 100644 --- a/src/Query/Fuzzy.php +++ b/src/Query/Fuzzy.php @@ -18,6 +18,8 @@ public function __construct( private int $fuzziness = 2, private int $prefixLength = 0, private int $maxExpansion = 100, + private bool|null $transpositions = null, + private string|null $rewrite = null, ) { } @@ -29,22 +31,32 @@ public function key(): string } + /** + * @return array>> + */ public function toArray(): array { - // phpcs:ignore SlevomatCodingStandard.Variables.UselessVariable - $array = [ + $body = [ + 'value' => $this->query, + 'boost' => $this->boost, + 'fuzziness' => $this->fuzziness, + 'prefix_length' => $this->prefixLength, + 'max_expansions' => $this->maxExpansion, + ]; + + if ($this->transpositions !== null) { + $body['transpositions'] = $this->transpositions; + } + + if ($this->rewrite !== null) { + $body['rewrite'] = $this->rewrite; + } + + return [ 'fuzzy' => [ - $this->field => [ - 'value' => $this->query, - 'boost' => $this->boost, - 'fuzziness' => $this->fuzziness, - 'prefix_length' => $this->prefixLength, - 'max_expansions' => $this->maxExpansion, - ], + $this->field => $body, ], ]; - - return $array; } } diff --git a/src/Query/Prefix.php b/src/Query/Prefix.php index 2aa856d..2bcf113 100644 --- a/src/Query/Prefix.php +++ b/src/Query/Prefix.php @@ -15,6 +15,7 @@ public function __construct( private string $query, private float $boost = 1.0, private bool|null $caseInsensitive = null, + private string|null $rewrite = null, ) { } @@ -40,6 +41,10 @@ public function toArray(): array $body['case_insensitive'] = $this->caseInsensitive; } + if ($this->rewrite !== null) { + $body['rewrite'] = $this->rewrite; + } + return [ 'prefix' => [ $this->field => $body, diff --git a/src/Query/Range.php b/src/Query/Range.php index a94a4c8..4e61e50 100644 --- a/src/Query/Range.php +++ b/src/Query/Range.php @@ -16,71 +16,98 @@ public function __construct( private float|\DateTimeInterface|int|string|null $gte = null, private float|\DateTimeInterface|int|string|null $lte = null, private float $boost = 1.0, + private float|\DateTimeInterface|int|string|null $gt = null, + private float|\DateTimeInterface|int|string|null $lt = null, + private string|null $format = null, + private string|null $relation = null, + private string|null $timeZone = null, ) { - if ($gte === null && $lte === null) { + if ($gte === null && $lte === null && $gt === null && $lt === null) { throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( 'Range must have at least one border value.', ); } if ($lte && $gte && $lte < $gte) { - if ($gte instanceof \DateTimeInterface) { - $gteValue = $gte->format('U'); - - } else { - $gteValue = $gte; - } - - if ($lte instanceof \DateTimeInterface) { - $lteValue = $lte->format('U'); + $this->throwInvalidRange('gte', $gte, 'lte', $lte); + } - } else { - $lteValue = $lte; - } + if ($lt && $gt && $lt < $gt) { + $this->throwInvalidRange('gt', $gt, 'lt', $lt); + } + if ($relation !== null && ! \in_array($relation, \Spameri\ElasticQuery\Query\Range\Relation::RELATIONS, true)) { throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( - 'Input values does not make range. From: ' . $gteValue . ' To: ' . $lteValue, + 'Range relation ' . $relation . ' is invalid, see \Spameri\ElasticQuery\Query\Range\Relation::RELATIONS.', ); } + } + + + private function throwInvalidRange( + string $fromKey, + float|\DateTimeInterface|int|string $from, + string $toKey, + float|\DateTimeInterface|int|string $to, + ): never + { + $fromValue = $from instanceof \DateTimeInterface ? $from->format('U') : $from; + $toValue = $to instanceof \DateTimeInterface ? $to->format('U') : $to; + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Input values do not make a range. ' . $fromKey . ': ' . $fromValue . ' ' . $toKey . ': ' . $toValue, + ); } public function key(): string { - $gte = $this->gte instanceof \DateTimeInterface ? $this->gte->format('Y-m-d H:i:s') : $this->gte; - $lte = $this->lte instanceof \DateTimeInterface ? $this->lte->format('Y-m-d H:i:s') : $this->lte; + $parts = []; + foreach (['gte' => $this->gte, 'lte' => $this->lte, 'gt' => $this->gt, 'lt' => $this->lt] as $name => $value) { + if ($value === null) { + continue; + } + $parts[] = $name . ':' . ($value instanceof \DateTimeInterface ? $value->format('Y-m-d H:i:s') : $value); + } - return 'range_' . $this->field . '_' . $gte . '_' . $lte; + return 'range_' . $this->field . '_' . \implode('_', $parts); } + /** + * @return array>> + */ public function toArray(): array { - $array = [ - 'range' => [ - $this->field => [ - 'boost' => $this->boost, - ], - ], - ]; + $body = ['boost' => $this->boost]; + + foreach (['gte' => $this->gte, 'lte' => $this->lte, 'gt' => $this->gt, 'lt' => $this->lt] as $name => $value) { + if ($value === null) { + continue; + } + $body[$name] = $value instanceof \DateTimeInterface + ? $value->format('Y-m-d H:i:s') + : $value; + } - if ($this->gte !== null) { - $array['range'][$this->field]['gte'] = - $this->gte instanceof \DateTimeInterface - ? $this->gte->format('Y-m-d H:i:s') - : $this->gte; + if ($this->format !== null) { + $body['format'] = $this->format; } - if ($this->lte !== null) { - $array['range'][$this->field]['lte'] = - $this->lte instanceof \DateTimeInterface - ? $this->lte->format('Y-m-d H:i:s') - : $this->lte; + if ($this->relation !== null) { + $body['relation'] = $this->relation; } - return $array; + if ($this->timeZone !== null) { + $body['time_zone'] = $this->timeZone; + } + + return [ + 'range' => [ + $this->field => $body, + ], + ]; } } diff --git a/src/Query/Range/Relation.php b/src/Query/Range/Relation.php new file mode 100644 index 0000000..365ed42 --- /dev/null +++ b/src/Query/Range/Relation.php @@ -0,0 +1,24 @@ + self::INTERSECTS, + self::CONTAINS => self::CONTAINS, + self::WITHIN => self::WITHIN, + ]; + +} diff --git a/src/Query/Regexp.php b/src/Query/Regexp.php index c9a1cc4..a1f3206 100644 --- a/src/Query/Regexp.php +++ b/src/Query/Regexp.php @@ -17,6 +17,7 @@ public function __construct( private string|null $flags = null, private bool|null $caseInsensitive = null, private int|null $maxDeterminizedStates = null, + private string|null $rewrite = null, ) { } @@ -50,6 +51,10 @@ public function toArray(): array $body['max_determinized_states'] = $this->maxDeterminizedStates; } + if ($this->rewrite !== null) { + $body['rewrite'] = $this->rewrite; + } + return [ 'regexp' => [ $this->field => $body, diff --git a/src/Query/Term.php b/src/Query/Term.php index 34877a5..2b3900c 100644 --- a/src/Query/Term.php +++ b/src/Query/Term.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html */ @@ -14,6 +15,7 @@ public function __construct( private string $field, private float|bool|int|string $query, private float $boost = 1.0, + private bool|null $caseInsensitive = null, ) { } @@ -25,14 +27,23 @@ public function key(): string } + /** + * @return array>> + */ public function toArray(): array { + $body = [ + 'value' => $this->query, + 'boost' => $this->boost, + ]; + + if ($this->caseInsensitive !== null) { + $body['case_insensitive'] = $this->caseInsensitive; + } + return [ 'term' => [ - $this->field => [ - 'value' => $this->query, - 'boost' => $this->boost, - ], + $this->field => $body, ], ]; } diff --git a/src/Query/TermSet.php b/src/Query/TermSet.php index b52735b..ad3a2ad 100644 --- a/src/Query/TermSet.php +++ b/src/Query/TermSet.php @@ -18,6 +18,7 @@ public function __construct( private array $terms, private string|null $minimumShouldMatchField = null, private string|null $minimumShouldMatchScript = null, + private float $boost = 1.0, ) { if ($terms === []) { @@ -47,6 +48,7 @@ public function toArray(): array { $body = [ 'terms' => $this->terms, + 'boost' => $this->boost, ]; if ($this->minimumShouldMatchField !== null) { diff --git a/src/Query/Terms.php b/src/Query/Terms.php index 5d05908..b18472d 100644 --- a/src/Query/Terms.php +++ b/src/Query/Terms.php @@ -11,13 +11,16 @@ class Terms implements LeafQueryInterface { + /** + * @param array|\Spameri\ElasticQuery\Query\TermsLookup $query Either inline values or a terms_lookup. + */ public function __construct( private string $field, - private array $query, + private array|\Spameri\ElasticQuery\Query\TermsLookup $query, private float $boost = 1.0, ) { - if ( ! \count($query)) { + if (\is_array($query) && \count($query) === 0) { throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( 'Terms query must contain values, empty array given.', ); @@ -28,12 +31,28 @@ public function __construct( public function key(): string { - return 'terms_' . $this->field . '_' . \implode('-', $this->query); + if ($this->query instanceof \Spameri\ElasticQuery\Query\TermsLookup) { + return 'terms_' . $this->field . '_lookup'; + } + + return 'terms_' . $this->field . '_' . \implode('-', \array_map('\strval', $this->query)); } + /** + * @return array> + */ public function toArray(): array { + if ($this->query instanceof \Spameri\ElasticQuery\Query\TermsLookup) { + return [ + 'terms' => [ + $this->field => $this->query->toArray(), + 'boost' => $this->boost, + ], + ]; + } + return [ 'terms' => [ $this->field => $this->query, diff --git a/src/Query/TermsLookup.php b/src/Query/TermsLookup.php new file mode 100644 index 0000000..5bb5a0c --- /dev/null +++ b/src/Query/TermsLookup.php @@ -0,0 +1,44 @@ + + */ + public function toArray(): array + { + $array = [ + 'index' => $this->index, + 'id' => $this->id, + 'path' => $this->path, + ]; + + if ($this->routing !== null) { + $array['routing'] = $this->routing; + } + + return $array; + } + +} diff --git a/src/Query/WildCard.php b/src/Query/WildCard.php index 2bc59db..a4779e6 100644 --- a/src/Query/WildCard.php +++ b/src/Query/WildCard.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wildcard-query.html */ @@ -14,6 +15,8 @@ public function __construct( private string $field, private string $query, private float $boost = 1.0, + private bool|null $caseInsensitive = null, + private string|null $rewrite = null, ) { } @@ -25,14 +28,27 @@ public function key(): string } + /** + * @return array>> + */ public function toArray(): array { + $body = [ + 'value' => $this->query, + 'boost' => $this->boost, + ]; + + if ($this->caseInsensitive !== null) { + $body['case_insensitive'] = $this->caseInsensitive; + } + + if ($this->rewrite !== null) { + $body['rewrite'] = $this->rewrite; + } + return [ 'wildcard' => [ - $this->field => [ - 'value' => $this->query, - 'boost' => $this->boost, - ], + $this->field => $body, ], ]; } diff --git a/tests/SpameriTests/ElasticQuery/Query/Exists.phpt b/tests/SpameriTests/ElasticQuery/Query/Exists.phpt index f0552dd..c77bc01 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Exists.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Exists.phpt @@ -5,103 +5,40 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Exists extends \Tester\TestCase +class Exists extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_exists'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_query_exists'; public function testToArray(): void { - $exists = new \Spameri\ElasticQuery\Query\Exists('user'); + $exists = new \Spameri\ElasticQuery\Query\Exists('user', 2.0); $array = $exists->toArray(); - \Tester\Assert::true(isset($array['exists'])); \Tester\Assert::same('user', $array['exists']['field']); - } - - - public function testKey(): void - { - $exists = new \Spameri\ElasticQuery\Query\Exists('email'); - - \Tester\Assert::same('exits_email', $exists->key()); - } - - - public function testNestedFieldPath(): void - { - $exists = new \Spameri\ElasticQuery\Query\Exists('user.profile.avatar'); - - $array = $exists->toArray(); - - \Tester\Assert::same('user.profile.avatar', $array['exists']['field']); + \Tester\Assert::same(2.0, $array['exists']['boost']); } public function testCreate(): void { - $exists = new \Spameri\ElasticQuery\Query\Exists('title'); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - null, - new \Spameri\ElasticQuery\Query\MustCollection( - $exists, - ), - ), - ) - )->toArray(), + $this->indexDocument(['title' => 'foo']); + $this->indexDocument(['other' => 'bar']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Exists('title'), + ), ), ); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type('int', $result->stats()->total()); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Fuzzy.phpt b/tests/SpameriTests/ElasticQuery/Query/Fuzzy.phpt index 0880bf9..03134d6 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Fuzzy.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Fuzzy.phpt @@ -5,89 +5,86 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Fuzzy extends \Tester\TestCase +class Fuzzy extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_fuzzy'; + protected const INDEX = 'spameri_test_query_fuzzy'; - public function setUp() : void + public function testToArray(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $fuzzy = new \Spameri\ElasticQuery\Query\Fuzzy('name', 'Avengers', 1.0, 2, 0, 100); - \curl_exec($ch); + $array = $fuzzy->toArray(); + + \Tester\Assert::same('Avengers', $array['fuzzy']['name']['value']); + \Tester\Assert::same(1.0, $array['fuzzy']['name']['boost']); + \Tester\Assert::same(2, $array['fuzzy']['name']['fuzziness']); + \Tester\Assert::same(0, $array['fuzzy']['name']['prefix_length']); + \Tester\Assert::same(100, $array['fuzzy']['name']['max_expansions']); } - public function testCreate() : void + public function testToArrayWithTranspositionsAndRewrite(): void { $fuzzy = new \Spameri\ElasticQuery\Query\Fuzzy( - 'name', - 'Avengers', - 1.0, - 2, - 0, - 100 + field: 'name', + query: 'Avengers', + transpositions: false, + rewrite: 'constant_score', ); $array = $fuzzy->toArray(); - \Tester\Assert::true(isset($array['fuzzy']['name']['value'])); - \Tester\Assert::same('Avengers', $array['fuzzy']['name']['value']); - \Tester\Assert::same(1.0, $array['fuzzy']['name']['boost']); - \Tester\Assert::same(2, $array['fuzzy']['name']['fuzziness']); - \Tester\Assert::same(0, $array['fuzzy']['name']['prefix_length']); - \Tester\Assert::same(100, $array['fuzzy']['name']['max_expansions']); + \Tester\Assert::false($array['fuzzy']['name']['transpositions']); + \Tester\Assert::same('constant_score', $array['fuzzy']['name']['rewrite']); + } - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - NULL, - new \Spameri\ElasticQuery\Query\MustCollection( - $fuzzy - ) - ) - ) - )->toArray() - ) - ); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']) + public function testCreate(): void + { + $this->indexDocument(['name' => 'Avengers']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Fuzzy('name', 'Avengars'), + ), + ), ); - \Tester\Assert::noError(static function () use ($ch) { - $response = curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, TRUE)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown() : void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['name' => 'Avengers']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Fuzzy( + field: 'name', + query: 'Avengars', + boost: 1.0, + fuzziness: 2, + prefixLength: 0, + maxExpansion: 50, + transpositions: true, + rewrite: 'constant_score', + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Prefix.phpt b/tests/SpameriTests/ElasticQuery/Query/Prefix.phpt index dd3e29f..f10eec3 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Prefix.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Prefix.phpt @@ -5,31 +5,17 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Prefix extends \Tester\TestCase +class Prefix extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_prefix'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_query_prefix'; public function testToArray(): void { $prefix = new \Spameri\ElasticQuery\Query\Prefix('user', 'ki'); - $array = $prefix->toArray(); - - \Tester\Assert::same('ki', $array['prefix']['user']['value']); + \Tester\Assert::same('ki', $prefix->toArray()['prefix']['user']['value']); } @@ -39,66 +25,62 @@ class Prefix extends \Tester\TestCase field: 'user', query: 'ki', caseInsensitive: true, + rewrite: 'constant_score', ); \Tester\Assert::true($prefix->toArray()['prefix']['user']['case_insensitive']); + \Tester\Assert::same('constant_score', $prefix->toArray()['prefix']['user']['rewrite']); } public function testKey(): void { $prefix = new \Spameri\ElasticQuery\Query\Prefix('user', 'ki'); - \Tester\Assert::same('prefix_user_ki', $prefix->key()); } public function testCreate(): void { - $prefix = new \Spameri\ElasticQuery\Query\Prefix('user', 'ki'); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - (new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - null, - new \Spameri\ElasticQuery\Query\MustCollection($prefix), - ), - ))->toArray(), + $this->indexDocument(['user' => 'kimchy']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Prefix('user', 'ki'), + ), ), ); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); + $result = $this->search($query); - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown(): void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['user' => 'kimchy']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Prefix( + field: 'user', + query: 'ki', + boost: 2.0, + caseInsensitive: false, + rewrite: 'constant_score', + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Range.phpt b/tests/SpameriTests/ElasticQuery/Query/Range.phpt index 3d969cc..86537ff 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Range.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Range.phpt @@ -5,85 +5,124 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Range extends \Tester\TestCase +class Range extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_range'; + protected const INDEX = 'spameri_test_query_range'; - public function setUp() : void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'id' => ['type' => 'long'], + 'created' => ['type' => 'date'], + ], + ], + ]; } - public function testCreate() : void + public function testToArray(): void { - $range = new \Spameri\ElasticQuery\Query\Range( - 'id', - 1, - 1000000, - 1.0 - ); + $range = new \Spameri\ElasticQuery\Query\Range('id', 1, 1000000, 1.0); $array = $range->toArray(); - \Tester\Assert::true(isset($array['range']['id'])); \Tester\Assert::same(1, $array['range']['id']['gte']); \Tester\Assert::same(1000000, $array['range']['id']['lte']); \Tester\Assert::same(1.0, $array['range']['id']['boost']); + } + + + public function testToArrayWithGtLtRelationFormat(): void + { + $range = new \Spameri\ElasticQuery\Query\Range( + field: 'id', + boost: 1.0, + gt: 1, + lt: 10, + relation: \Spameri\ElasticQuery\Query\Range\Relation::WITHIN, + format: 'epoch_second', + timeZone: 'UTC', + ); + + $array = $range->toArray(); + + \Tester\Assert::same(1, $array['range']['id']['gt']); + \Tester\Assert::same(10, $array['range']['id']['lt']); + \Tester\Assert::same('WITHIN', $array['range']['id']['relation']); + \Tester\Assert::same('epoch_second', $array['range']['id']['format']); + \Tester\Assert::same('UTC', $array['range']['id']['time_zone']); + } - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - NULL, - new \Spameri\ElasticQuery\Query\MustCollection( - $range - ) - ) - ) - )->toArray() - ) + + public function testRejectsEmptyRange(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\Range('id'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, ); + } + - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']) + public function testRejectsInvalidRelation(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\Range('id', 1, 10, relation: 'OVERLAPS'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, ); + } + - \Tester\Assert::noError(static function () use ($ch) { - $response = curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, TRUE)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + public function testCreate(): void + { + $this->indexDocument(['id' => 5]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Range('id', 1, 10), + ), + ), + ); + + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown() : void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['id' => 5, 'created' => '2024-01-01']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Range( + field: 'created', + gte: '2023-01-01', + lte: '2025-01-01', + boost: 1.0, + format: 'yyyy-MM-dd', + relation: \Spameri\ElasticQuery\Query\Range\Relation::INTERSECTS, + timeZone: 'UTC', + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Regexp.phpt b/tests/SpameriTests/ElasticQuery/Query/Regexp.phpt index 1aa28e0..4565311 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Regexp.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Regexp.phpt @@ -5,21 +5,15 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Regexp extends \Tester\TestCase +class Regexp extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_regexp'; + protected const INDEX = 'spameri_test_query_regexp'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['user' => ['type' => 'keyword']]]]; } @@ -27,66 +21,72 @@ class Regexp extends \Tester\TestCase { $regexp = new \Spameri\ElasticQuery\Query\Regexp('user', 'k.*y'); - $array = $regexp->toArray(); + \Tester\Assert::same('k.*y', $regexp->toArray()['regexp']['user']['value']); + } + + + public function testRewriteOption(): void + { + $regexp = new \Spameri\ElasticQuery\Query\Regexp( + field: 'user', + query: 'k.*', + rewrite: 'constant_score', + ); - \Tester\Assert::same('k.*y', $array['regexp']['user']['value']); + \Tester\Assert::same('constant_score', $regexp->toArray()['regexp']['user']['rewrite']); } public function testKey(): void { $regexp = new \Spameri\ElasticQuery\Query\Regexp('user', 'k.*'); - \Tester\Assert::same('regexp_user_k.*', $regexp->key()); } public function testCreate(): void { - $regexp = new \Spameri\ElasticQuery\Query\Regexp('user', 'k.*'); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - (new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - null, - new \Spameri\ElasticQuery\Query\MustCollection($regexp), - ), - ))->toArray(), + $this->indexDocument(['user' => 'kimchy']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Regexp('user', 'k.*'), + ), ), ); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); + $result = $this->search($query); - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown(): void + public function testCreateWithAllOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['user' => 'kimchy']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Regexp( + field: 'user', + query: 'k.*', + boost: 2.0, + flags: 'ALL', + caseInsensitive: false, + maxDeterminizedStates: 10000, + rewrite: 'constant_score', + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/TermSet.phpt b/tests/SpameriTests/ElasticQuery/Query/TermSet.phpt index d4ba06d..814a05e 100644 --- a/tests/SpameriTests/ElasticQuery/Query/TermSet.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/TermSet.phpt @@ -5,21 +5,22 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class TermSet extends \Tester\TestCase +class TermSet extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_term_set'; + protected const INDEX = 'spameri_test_query_term_set'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'programming_languages' => ['type' => 'keyword'], + 'required_matches' => ['type' => 'long'], + ], + ], + ]; } @@ -29,6 +30,7 @@ class TermSet extends \Tester\TestCase field: 'programming_languages', terms: ['c++', 'java', 'php'], minimumShouldMatchField: 'required_matches', + boost: 2.0, ); $array = $termSet->toArray(); @@ -37,6 +39,7 @@ class TermSet extends \Tester\TestCase ['c++', 'java', 'php'], $array['terms_set']['programming_languages']['terms'], ); + \Tester\Assert::same(2.0, $array['terms_set']['programming_languages']['boost']); \Tester\Assert::same( 'required_matches', $array['terms_set']['programming_languages']['minimum_should_match_field'], @@ -66,27 +69,29 @@ class TermSet extends \Tester\TestCase } - public function testKey(): void + public function testCreate(): void { - $termSet = new \Spameri\ElasticQuery\Query\TermSet( - 'tags', - ['php', 'es'], - minimumShouldMatchField: 'min', + $this->indexDocument([ + 'programming_languages' => ['c++', 'java', 'php'], + 'required_matches' => 2, + ]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\TermSet( + field: 'programming_languages', + terms: ['c++', 'java', 'php'], + minimumShouldMatchField: 'required_matches', + ), + ), + ), ); - \Tester\Assert::same('terms_set_tags_php-es', $termSet->key()); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Terms.phpt b/tests/SpameriTests/ElasticQuery/Query/Terms.phpt index edde1e6..a2d964b 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Terms.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Terms.phpt @@ -5,82 +5,99 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Terms extends \Tester\TestCase +class Terms extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_terms'; + protected const INDEX = 'spameri_test_query_terms'; + private const LOOKUP_INDEX = 'spameri_test_query_terms_lookup'; - public function setUp() : void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'name' => ['type' => 'keyword'], + ], + ], + ]; } - public function testCreate() : void + + public function testToArray(): void { - $terms = new \Spameri\ElasticQuery\Query\Terms( - 'name', - ['Avengers'], - 1.0 - ); + $terms = new \Spameri\ElasticQuery\Query\Terms('name', ['Avengers'], 1.0); $array = $terms->toArray(); - \Tester\Assert::true(isset($array['terms']['name'][0])); \Tester\Assert::same('Avengers', $array['terms']['name'][0]); \Tester\Assert::same(1.0, $array['terms']['boost']); + } + - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - NULL, - new \Spameri\ElasticQuery\Query\MustCollection( - $terms - ) - ) - ) - )->toArray() - ) + public function testToArrayWithLookup(): void + { + $lookup = new \Spameri\ElasticQuery\Query\TermsLookup( + index: 'users', + id: '42', + path: 'friends', ); + $terms = new \Spameri\ElasticQuery\Query\Terms('user_id', $lookup); + + $array = $terms->toArray(); + + \Tester\Assert::same('users', $array['terms']['user_id']['index']); + \Tester\Assert::same('42', $array['terms']['user_id']['id']); + \Tester\Assert::same('friends', $array['terms']['user_id']['path']); + } - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']) + + public function testCreate(): void + { + $this->indexDocument(['name' => 'Avengers']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Terms('name', ['Avengers']), + ), + ), ); - \Tester\Assert::noError(static function () use ($ch) { - $response = curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, TRUE)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown() : void + public function testCreateWithLookup(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->request('PUT', self::LOOKUP_INDEX, ['mappings' => ['properties' => ['ids' => ['type' => 'keyword']]]]); + $this->request('PUT', self::LOOKUP_INDEX . '/_doc/list?refresh=true', ['ids' => ['Avengers', 'Endgame']]); + + $this->indexDocument(['name' => 'Avengers']); + $this->indexDocument(['name' => 'Other']); + + $lookup = new \Spameri\ElasticQuery\Query\TermsLookup( + index: self::LOOKUP_INDEX, + id: 'list', + path: 'ids', + ); + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Terms('name', $lookup), + ), + ), + ); + + $result = $this->search($query); + + $this->request('DELETE', self::LOOKUP_INDEX); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/WildCard.phpt b/tests/SpameriTests/ElasticQuery/Query/WildCard.phpt index f69290a..6def0ae 100644 --- a/tests/SpameriTests/ElasticQuery/Query/WildCard.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/WildCard.phpt @@ -5,86 +5,82 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class WildCard extends \Tester\TestCase +class WildCard extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_video_wildcard'; + protected const INDEX = 'spameri_test_query_wildcard'; - public function setUp() : void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['name' => ['type' => 'keyword']]]]; } - public function testCreate() : void + public function testToArray(): void { - $wildCard = new \Spameri\ElasticQuery\Query\WildCard( - 'name', - 'Avengers', - 1.0 - ); + $wildCard = new \Spameri\ElasticQuery\Query\WildCard('name', 'Aveng*', 1.0); $array = $wildCard->toArray(); - \Tester\Assert::true(isset($array['wildcard']['name']['value'])); - \Tester\Assert::same('Avengers', $array['wildcard']['name']['value']); + \Tester\Assert::same('Aveng*', $array['wildcard']['name']['value']); \Tester\Assert::same(1.0, $array['wildcard']['name']['boost']); + } - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - ( - new \Spameri\ElasticQuery\ElasticQuery( - new \Spameri\ElasticQuery\Query\QueryCollection( - NULL, - new \Spameri\ElasticQuery\Query\MustCollection( - $wildCard - ) - ) - ) - )->toArray() - ) + + public function testToArrayWithCaseInsensitive(): void + { + $wildCard = new \Spameri\ElasticQuery\Query\WildCard( + field: 'name', + query: 'aveng*', + caseInsensitive: true, + rewrite: 'constant_score', ); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - curl_setopt( - $ch, CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']) + \Tester\Assert::true($wildCard->toArray()['wildcard']['name']['case_insensitive']); + \Tester\Assert::same('constant_score', $wildCard->toArray()['wildcard']['name']['rewrite']); + } + + + public function testCreate(): void + { + $this->indexDocument(['name' => 'Avengers']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\WildCard('name', 'Aveng*'), + ), + ), ); - \Tester\Assert::noError(static function () use ($ch) { - $response = \curl_exec($ch); - if ($response === false) { - throw new \RuntimeException('Curl request failed: ' . \curl_error($ch)); - } - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type('int', $result->stats()->total()); - }); + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown() : void + public function testCreateWithCaseInsensitive(): void { - $ch = \curl_init(); - \curl_setopt($ch, CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX . '/'); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['name' => 'Avengers']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\WildCard( + field: 'name', + query: 'aveng*', + caseInsensitive: true, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } From 0da8389804aa2640de9b11a535161419fac11c83 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:00:30 +0200 Subject: [PATCH 84/97] feat(query): inner_hits on joining queries; ParentId boost - HasChild: accepts InnerHits via constructor - HasParent: accepts InnerHits via constructor - ParentId: gains a boost arg Tests now build a real parent-join index (relations: blog -> comment), index a parent and a routed child, and round-trip the query through ES with inner_hits to confirm the joined hit comes back. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Query/HasChild.php | 5 ++ src/Query/HasParent.php | 5 ++ src/Query/ParentId.php | 2 + .../ElasticQuery/Query/HasChild.phpt | 70 ++++++++++++++----- .../ElasticQuery/Query/HasParent.phpt | 68 +++++++++++++----- .../ElasticQuery/Query/ParentId.phpt | 55 +++++++++------ 6 files changed, 151 insertions(+), 54 deletions(-) diff --git a/src/Query/HasChild.php b/src/Query/HasChild.php index 3076594..4237618 100644 --- a/src/Query/HasChild.php +++ b/src/Query/HasChild.php @@ -17,6 +17,7 @@ public function __construct( private int|null $minChildren = null, private int|null $maxChildren = null, private bool|null $ignoreUnmapped = null, + private \Spameri\ElasticQuery\Query\InnerHits|null $innerHits = null, ) { } @@ -54,6 +55,10 @@ public function toArray(): array $body['ignore_unmapped'] = $this->ignoreUnmapped; } + if ($this->innerHits !== null) { + $body['inner_hits'] = $this->innerHits->toArray(); + } + return [ 'has_child' => $body, ]; diff --git a/src/Query/HasParent.php b/src/Query/HasParent.php index 05eaab2..74dae02 100644 --- a/src/Query/HasParent.php +++ b/src/Query/HasParent.php @@ -15,6 +15,7 @@ public function __construct( private \Spameri\ElasticQuery\Query\LeafQueryInterface $query, private bool|null $score = null, private bool|null $ignoreUnmapped = null, + private \Spameri\ElasticQuery\Query\InnerHits|null $innerHits = null, ) { } @@ -44,6 +45,10 @@ public function toArray(): array $body['ignore_unmapped'] = $this->ignoreUnmapped; } + if ($this->innerHits !== null) { + $body['inner_hits'] = $this->innerHits->toArray(); + } + return [ 'has_parent' => $body, ]; diff --git a/src/Query/ParentId.php b/src/Query/ParentId.php index 4a8a004..65c1342 100644 --- a/src/Query/ParentId.php +++ b/src/Query/ParentId.php @@ -14,6 +14,7 @@ public function __construct( private string $type, private string $id, private bool|null $ignoreUnmapped = null, + private float $boost = 1.0, ) { } @@ -33,6 +34,7 @@ public function toArray(): array $body = [ 'type' => $this->type, 'id' => $this->id, + 'boost' => $this->boost, ]; if ($this->ignoreUnmapped !== null) { diff --git a/tests/SpameriTests/ElasticQuery/Query/HasChild.phpt b/tests/SpameriTests/ElasticQuery/Query/HasChild.phpt index b745144..fa90a34 100644 --- a/tests/SpameriTests/ElasticQuery/Query/HasChild.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/HasChild.phpt @@ -5,21 +5,26 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class HasChild extends \Tester\TestCase +class HasChild extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_has_child'; + protected const INDEX = 'spameri_test_query_has_child'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'my_join_field' => [ + 'type' => 'join', + 'relations' => ['blog' => 'comment'], + ], + 'author' => ['type' => 'keyword'], + 'tag' => ['type' => 'keyword'], + ], + ], + ]; } @@ -40,6 +45,21 @@ class HasChild extends \Tester\TestCase } + public function testToArrayWithInnerHits(): void + { + $hasChild = new \Spameri\ElasticQuery\Query\HasChild( + type: 'comment', + query: new \Spameri\ElasticQuery\Query\Term('author', 'john'), + innerHits: new \Spameri\ElasticQuery\Query\InnerHits(name: 'matched', size: 3), + ); + + $array = $hasChild->toArray(); + + \Tester\Assert::same('matched', $array['has_child']['inner_hits']['name']); + \Tester\Assert::same(3, $array['has_child']['inner_hits']['size']); + } + + public function testKey(): void { $hasChild = new \Spameri\ElasticQuery\Query\HasChild( @@ -51,15 +71,31 @@ class HasChild extends \Tester\TestCase } - public function tearDown(): void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['tag' => 'tech', 'my_join_field' => 'blog'], id: '1'); + $this->request( + 'PUT', + self::INDEX . '/_doc/2?refresh=true&routing=1', + ['author' => 'john', 'my_join_field' => ['name' => 'comment', 'parent' => '1']], + ); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\HasChild( + type: 'comment', + query: new \Spameri\ElasticQuery\Query\Term('author', 'john'), + innerHits: new \Spameri\ElasticQuery\Query\InnerHits(), + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/HasParent.phpt b/tests/SpameriTests/ElasticQuery/Query/HasParent.phpt index ef06df9..cfcb491 100644 --- a/tests/SpameriTests/ElasticQuery/Query/HasParent.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/HasParent.phpt @@ -5,21 +5,26 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class HasParent extends \Tester\TestCase +class HasParent extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_has_parent'; + protected const INDEX = 'spameri_test_query_has_parent'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'my_join_field' => [ + 'type' => 'join', + 'relations' => ['blog' => 'comment'], + ], + 'tag' => ['type' => 'keyword'], + 'author' => ['type' => 'keyword'], + ], + ], + ]; } @@ -38,6 +43,18 @@ class HasParent extends \Tester\TestCase } + public function testToArrayWithInnerHits(): void + { + $hasParent = new \Spameri\ElasticQuery\Query\HasParent( + parentType: 'blog', + query: new \Spameri\ElasticQuery\Query\Term('tag', 'tech'), + innerHits: new \Spameri\ElasticQuery\Query\InnerHits(name: 'parent'), + ); + + \Tester\Assert::same('parent', $hasParent->toArray()['has_parent']['inner_hits']['name']); + } + + public function testKey(): void { $hasParent = new \Spameri\ElasticQuery\Query\HasParent( @@ -49,15 +66,32 @@ class HasParent extends \Tester\TestCase } - public function tearDown(): void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['tag' => 'tech', 'my_join_field' => 'blog'], id: '1'); + $this->request( + 'PUT', + self::INDEX . '/_doc/2?refresh=true&routing=1', + ['author' => 'john', 'my_join_field' => ['name' => 'comment', 'parent' => '1']], + ); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\HasParent( + parentType: 'blog', + query: new \Spameri\ElasticQuery\Query\Term('tag', 'tech'), + score: true, + innerHits: new \Spameri\ElasticQuery\Query\InnerHits(), + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/ParentId.phpt b/tests/SpameriTests/ElasticQuery/Query/ParentId.phpt index 3907956..abc6804 100644 --- a/tests/SpameriTests/ElasticQuery/Query/ParentId.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/ParentId.phpt @@ -5,52 +5,67 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class ParentId extends \Tester\TestCase +class ParentId extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_parent_id'; + protected const INDEX = 'spameri_test_query_parent_id'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'my_join_field' => [ + 'type' => 'join', + 'relations' => ['blog' => 'comment'], + ], + ], + ], + ]; } public function testToArray(): void { - $parentId = new \Spameri\ElasticQuery\Query\ParentId(type: 'comment', id: '1'); + $parentId = new \Spameri\ElasticQuery\Query\ParentId(type: 'comment', id: '1', boost: 2.0); $array = $parentId->toArray(); \Tester\Assert::same('comment', $array['parent_id']['type']); \Tester\Assert::same('1', $array['parent_id']['id']); + \Tester\Assert::same(2.0, $array['parent_id']['boost']); } public function testKey(): void { $parentId = new \Spameri\ElasticQuery\Query\ParentId('comment', '1'); - \Tester\Assert::same('parent_id_comment_1', $parentId->key()); } - public function tearDown(): void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + $this->indexDocument(['my_join_field' => 'blog'], id: '1'); + $this->request( + 'PUT', + self::INDEX . '/_doc/2?refresh=true&routing=1', + ['my_join_field' => ['name' => 'comment', 'parent' => '1']], + ); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\ParentId(type: 'comment', id: '1'), + ), + ), + ); + + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); } } From caadba66fa9b83da22afa2799b8e51b97e557a1b Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:08:17 +0200 Subject: [PATCH 85/97] feat(query): IndexedShape support + extra geo args - GeoBoundingBox: validation_method, ignore_unmapped, boost - GeoShape: indexed_shape (new IndexedShape sub-object), boost - Shape: indexed_shape, boost IndexedShape resolves pre-indexed shapes by {id, index, path, routing}. GeoShape/Shape now require either inline shape or indexedShape. Tests cover both inline and indexed-shape paths end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Query/GeoBoundingBox.php | 13 ++ src/Query/GeoShape.php | 36 ++++-- src/Query/IndexedShape.php | 44 +++++++ src/Query/Shape.php | 35 ++++-- .../ElasticQuery/Query/GeoBoundingBox.phpt | 63 ++++++---- .../ElasticQuery/Query/GeoShape.phpt | 111 ++++++++++++++---- .../ElasticQuery/Query/Shape.phpt | 55 +++++---- 7 files changed, 276 insertions(+), 81 deletions(-) create mode 100644 src/Query/IndexedShape.php diff --git a/src/Query/GeoBoundingBox.php b/src/Query/GeoBoundingBox.php index 72a8690..ed60cbc 100644 --- a/src/Query/GeoBoundingBox.php +++ b/src/Query/GeoBoundingBox.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-bounding-box-query.html */ @@ -17,6 +18,9 @@ public function __construct( private float $bottomRightLat, private float $bottomRightLon, private string|null $type = null, + private string|null $validationMethod = null, + private bool|null $ignoreUnmapped = null, + private float $boost = 1.0, ) { } @@ -44,12 +48,21 @@ public function toArray(): array 'lon' => $this->bottomRightLon, ], ], + 'boost' => $this->boost, ]; if ($this->type !== null) { $body['type'] = $this->type; } + if ($this->validationMethod !== null) { + $body['validation_method'] = $this->validationMethod; + } + + if ($this->ignoreUnmapped !== null) { + $body['ignore_unmapped'] = $this->ignoreUnmapped; + } + return [ 'geo_bounding_box' => $body, ]; diff --git a/src/Query/GeoShape.php b/src/Query/GeoShape.php index bc0de49..8bd1fc1 100644 --- a/src/Query/GeoShape.php +++ b/src/Query/GeoShape.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-geo-shape-query.html */ @@ -11,14 +12,15 @@ class GeoShape implements \Spameri\ElasticQuery\Query\LeafQueryInterface { /** - * @param array $shape GeoJSON-style shape, e.g. - * ['type' => 'envelope', 'coordinates' => [[13, 53], [14, 52]]]. + * @param array|null $shape Inline GeoJSON shape; null when $indexedShape is used. */ public function __construct( private string $field, - private array $shape, + private array|null $shape = null, private string $relation = 'intersects', private bool|null $ignoreUnmapped = null, + private \Spameri\ElasticQuery\Query\IndexedShape|null $indexedShape = null, + private float $boost = 1.0, ) { if ( ! \in_array($relation, ['intersects', 'disjoint', 'within', 'contains'], true)) { @@ -26,6 +28,12 @@ public function __construct( 'GeoShape relation must be one of: intersects, disjoint, within, contains.', ); } + + if ($shape === null && $indexedShape === null) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'GeoShape requires either an inline shape or an indexedShape.', + ); + } } @@ -36,23 +44,33 @@ public function key(): string /** - * @return array>> + * @return array> */ public function toArray(): array { $inner = [ - 'shape' => $this->shape, 'relation' => $this->relation, ]; + if ($this->shape !== null) { + $inner['shape'] = $this->shape; + } + + if ($this->indexedShape !== null) { + $inner['indexed_shape'] = $this->indexedShape->toArray(); + } + + $body = [ + $this->field => $inner, + 'boost' => $this->boost, + ]; + if ($this->ignoreUnmapped !== null) { - $inner['ignore_unmapped'] = $this->ignoreUnmapped; + $body['ignore_unmapped'] = $this->ignoreUnmapped; } return [ - 'geo_shape' => [ - $this->field => $inner, - ], + 'geo_shape' => $body, ]; } diff --git a/src/Query/IndexedShape.php b/src/Query/IndexedShape.php new file mode 100644 index 0000000..0f4a503 --- /dev/null +++ b/src/Query/IndexedShape.php @@ -0,0 +1,44 @@ + + */ + public function toArray(): array + { + $array = [ + 'id' => $this->id, + 'index' => $this->index, + 'path' => $this->path, + ]; + + if ($this->routing !== null) { + $array['routing'] = $this->routing; + } + + return $array; + } + +} diff --git a/src/Query/Shape.php b/src/Query/Shape.php index a965f6d..66e08c4 100644 --- a/src/Query/Shape.php +++ b/src/Query/Shape.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-shape-query.html */ @@ -11,13 +12,15 @@ class Shape implements \Spameri\ElasticQuery\Query\LeafQueryInterface { /** - * @param array $shape + * @param array|null $shape Inline GeoJSON shape; null when $indexedShape is used. */ public function __construct( private string $field, - private array $shape, + private array|null $shape = null, private string $relation = 'intersects', private bool|null $ignoreUnmapped = null, + private \Spameri\ElasticQuery\Query\IndexedShape|null $indexedShape = null, + private float $boost = 1.0, ) { if ( ! \in_array($relation, ['intersects', 'disjoint', 'within', 'contains'], true)) { @@ -25,6 +28,12 @@ public function __construct( 'Shape relation must be one of: intersects, disjoint, within, contains.', ); } + + if ($shape === null && $indexedShape === null) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Shape requires either an inline shape or an indexedShape.', + ); + } } @@ -35,23 +44,33 @@ public function key(): string /** - * @return array>> + * @return array> */ public function toArray(): array { $inner = [ - 'shape' => $this->shape, 'relation' => $this->relation, ]; + if ($this->shape !== null) { + $inner['shape'] = $this->shape; + } + + if ($this->indexedShape !== null) { + $inner['indexed_shape'] = $this->indexedShape->toArray(); + } + + $body = [ + $this->field => $inner, + 'boost' => $this->boost, + ]; + if ($this->ignoreUnmapped !== null) { - $inner['ignore_unmapped'] = $this->ignoreUnmapped; + $body['ignore_unmapped'] = $this->ignoreUnmapped; } return [ - 'shape' => [ - $this->field => $inner, - ], + 'shape' => $body, ]; } diff --git a/tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt b/tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt index 19f76c4..8a53fe1 100644 --- a/tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/GeoBoundingBox.phpt @@ -5,21 +5,15 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class GeoBoundingBox extends \Tester\TestCase +class GeoBoundingBox extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_geo_bounding_box'; + protected const INDEX = 'spameri_test_query_geo_bounding_box'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['location' => ['type' => 'geo_point']]]]; } @@ -37,26 +31,55 @@ class GeoBoundingBox extends \Tester\TestCase \Tester\Assert::same(40.73, $array['geo_bounding_box']['location']['top_left']['lat']); \Tester\Assert::same(-71.12, $array['geo_bounding_box']['location']['bottom_right']['lon']); + \Tester\Assert::same(1.0, $array['geo_bounding_box']['boost']); } - public function testKey(): void + public function testToArrayWithAllOptions(): void { - $gbb = new \Spameri\ElasticQuery\Query\GeoBoundingBox('location', 1, 1, 0, 0); + $gbb = new \Spameri\ElasticQuery\Query\GeoBoundingBox( + field: 'location', + topLeftLat: 40.73, + topLeftLon: -74.1, + bottomRightLat: 40.01, + bottomRightLon: -71.12, + type: 'memory', + validationMethod: 'COERCE', + ignoreUnmapped: true, + boost: 2.0, + ); + + $array = $gbb->toArray(); - \Tester\Assert::same('geo_bounding_box_location', $gbb->key()); + \Tester\Assert::same('memory', $array['geo_bounding_box']['type']); + \Tester\Assert::same('COERCE', $array['geo_bounding_box']['validation_method']); + \Tester\Assert::true($array['geo_bounding_box']['ignore_unmapped']); + \Tester\Assert::same(2.0, $array['geo_bounding_box']['boost']); } - public function tearDown(): void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['location' => ['lat' => 40.5, 'lon' => -73.0]]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\GeoBoundingBox( + field: 'location', + topLeftLat: 41.0, + topLeftLon: -75.0, + bottomRightLat: 40.0, + bottomRightLon: -71.0, + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt b/tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt index 8546ceb..9ebff25 100644 --- a/tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/GeoShape.phpt @@ -5,21 +5,16 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class GeoShape extends \Tester\TestCase +class GeoShape extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_geo_shape'; + protected const INDEX = 'spameri_test_query_geo_shape'; + private const SHAPE_INDEX = 'spameri_test_query_geo_shape_lookup'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['location' => ['type' => 'geo_shape']]]]; } @@ -27,10 +22,7 @@ class GeoShape extends \Tester\TestCase { $geoShape = new \Spameri\ElasticQuery\Query\GeoShape( field: 'location', - shape: [ - 'type' => 'envelope', - 'coordinates' => [[13.0, 53.0], [14.0, 52.0]], - ], + shape: ['type' => 'envelope', 'coordinates' => [[13.0, 53.0], [14.0, 52.0]]], relation: 'within', ); @@ -41,6 +33,28 @@ class GeoShape extends \Tester\TestCase } + public function testToArrayWithIndexedShape(): void + { + $geoShape = new \Spameri\ElasticQuery\Query\GeoShape( + field: 'location', + indexedShape: new \Spameri\ElasticQuery\Query\IndexedShape( + id: 'deu', + index: 'shapes', + path: 'location', + routing: 'eu', + ), + boost: 2.0, + ); + + $array = $geoShape->toArray(); + + \Tester\Assert::same('deu', $array['geo_shape']['location']['indexed_shape']['id']); + \Tester\Assert::same('shapes', $array['geo_shape']['location']['indexed_shape']['index']); + \Tester\Assert::same('eu', $array['geo_shape']['location']['indexed_shape']['routing']); + \Tester\Assert::same(2.0, $array['geo_shape']['boost']); + } + + public function testRelationValidated(): void { \Tester\Assert::exception( @@ -52,23 +66,72 @@ class GeoShape extends \Tester\TestCase } - public function testKey(): void + public function testRequiresShapeOrIndexedShape(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\GeoShape('loc'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void { - $geoShape = new \Spameri\ElasticQuery\Query\GeoShape('location', ['type' => 'point', 'coordinates' => [0, 0]]); + $this->indexDocument(['location' => ['type' => 'point', 'coordinates' => [13.5, 52.5]]]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\GeoShape( + field: 'location', + shape: ['type' => 'envelope', 'coordinates' => [[13.0, 53.0], [14.0, 52.0]]], + relation: 'within', + ), + ), + ), + ); + + $result = $this->search($query); - \Tester\Assert::same('geo_shape_location', $geoShape->key()); + \Tester\Assert::same(1, $result->stats()->total()); } - public function tearDown(): void + public function testCreateWithIndexedShape(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->request('PUT', self::SHAPE_INDEX, ['mappings' => ['properties' => ['location' => ['type' => 'geo_shape']]]]); + $this->request( + 'PUT', + self::SHAPE_INDEX . '/_doc/deu?refresh=true', + ['location' => ['type' => 'envelope', 'coordinates' => [[13.0, 53.0], [14.0, 52.0]]]], + ); + $this->indexDocument(['location' => ['type' => 'point', 'coordinates' => [13.5, 52.5]]]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\GeoShape( + field: 'location', + relation: 'within', + indexedShape: new \Spameri\ElasticQuery\Query\IndexedShape( + id: 'deu', + index: self::SHAPE_INDEX, + path: 'location', + ), + ), + ), + ), + ); + + $result = $this->search($query); + + $this->request('DELETE', self::SHAPE_INDEX); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Shape.phpt b/tests/SpameriTests/ElasticQuery/Query/Shape.phpt index 8303185..7925b61 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Shape.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Shape.phpt @@ -5,21 +5,15 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Shape extends \Tester\TestCase +class Shape extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_shape'; + protected const INDEX = 'spameri_test_query_shape'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['geometry' => ['type' => 'shape']]]]; } @@ -34,26 +28,47 @@ class Shape extends \Tester\TestCase $array = $shape->toArray(); \Tester\Assert::same('envelope', $array['shape']['geometry']['shape']['type']); + \Tester\Assert::same('intersects', $array['shape']['geometry']['relation']); } - public function testKey(): void + public function testToArrayWithIndexedShape(): void { - $shape = new \Spameri\ElasticQuery\Query\Shape('geometry', ['type' => 'point', 'coordinates' => [0, 0]]); + $shape = new \Spameri\ElasticQuery\Query\Shape( + field: 'geometry', + indexedShape: new \Spameri\ElasticQuery\Query\IndexedShape( + id: 'box', + index: 'shapes', + path: 'geometry', + ), + boost: 1.5, + ); - \Tester\Assert::same('shape_geometry', $shape->key()); + \Tester\Assert::same('box', $shape->toArray()['shape']['geometry']['indexed_shape']['id']); + \Tester\Assert::same(1.5, $shape->toArray()['shape']['boost']); } - public function tearDown(): void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['geometry' => ['type' => 'point', 'coordinates' => [10, 10]]]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Shape( + field: 'geometry', + shape: ['type' => 'envelope', 'coordinates' => [[0, 100], [100, 0]]], + relation: 'intersects', + ), + ), + ), + ); + + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } From 59283d820b4016de6b08f35891cf001df51fbd31 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:10:02 +0200 Subject: [PATCH 86/97] feat(query): MoreLikeThis + Percolate full coverage MoreLikeThis: boost_terms, include, min_doc_freq, max_doc_freq, min_word_length, max_word_length, stop_words, analyzer, boost, fail_on_unsupported_field. Percolate: documents (multi-doc plural form), name, routing, preference, version. Percolate test now PUTs a real percolator mapping and a stored query, then percolates a candidate document through it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Query/MoreLikeThis.php | 49 ++++++++++++ src/Query/Percolate.php | 36 +++++++-- .../ElasticQuery/Query/MoreLikeThis.phpt | 77 +++++++++++------- .../ElasticQuery/Query/Percolate.phpt | 78 ++++++++++++------- 4 files changed, 181 insertions(+), 59 deletions(-) diff --git a/src/Query/MoreLikeThis.php b/src/Query/MoreLikeThis.php index c5839c1..c8d81e3 100644 --- a/src/Query/MoreLikeThis.php +++ b/src/Query/MoreLikeThis.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-mlt-query.html */ @@ -14,6 +15,7 @@ class MoreLikeThis implements \Spameri\ElasticQuery\Query\LeafQueryInterface * @param array $fields * @param array> $like Texts or doc refs (['_index' => ..., '_id' => ...]). * @param array> $unlike + * @param array $stopWords */ public function __construct( private array $fields, @@ -22,6 +24,16 @@ public function __construct( private int|null $minTermFreq = null, private int|null $maxQueryTerms = null, private int|string|null $minimumShouldMatch = null, + private float|null $boostTerms = null, + private bool|null $include = null, + private int|null $minDocFreq = null, + private int|null $maxDocFreq = null, + private int|null $minWordLength = null, + private int|null $maxWordLength = null, + private array $stopWords = [], + private string|null $analyzer = null, + private float $boost = 1.0, + private bool|null $failOnUnsupportedField = null, ) { if ($fields === []) { @@ -52,6 +64,7 @@ public function toArray(): array $body = [ 'fields' => $this->fields, 'like' => $this->like, + 'boost' => $this->boost, ]; if ($this->unlike !== []) { @@ -70,6 +83,42 @@ public function toArray(): array $body['minimum_should_match'] = $this->minimumShouldMatch; } + if ($this->boostTerms !== null) { + $body['boost_terms'] = $this->boostTerms; + } + + if ($this->include !== null) { + $body['include'] = $this->include; + } + + if ($this->minDocFreq !== null) { + $body['min_doc_freq'] = $this->minDocFreq; + } + + if ($this->maxDocFreq !== null) { + $body['max_doc_freq'] = $this->maxDocFreq; + } + + if ($this->minWordLength !== null) { + $body['min_word_length'] = $this->minWordLength; + } + + if ($this->maxWordLength !== null) { + $body['max_word_length'] = $this->maxWordLength; + } + + if ($this->stopWords !== []) { + $body['stop_words'] = $this->stopWords; + } + + if ($this->analyzer !== null) { + $body['analyzer'] = $this->analyzer; + } + + if ($this->failOnUnsupportedField !== null) { + $body['fail_on_unsupported_field'] = $this->failOnUnsupportedField; + } + return [ 'more_like_this' => $body, ]; diff --git a/src/Query/Percolate.php b/src/Query/Percolate.php index 230186a..c407caf 100644 --- a/src/Query/Percolate.php +++ b/src/Query/Percolate.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Query; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-percolate-query.html */ @@ -11,18 +12,24 @@ class Percolate implements \Spameri\ElasticQuery\Query\LeafQueryInterface { /** - * @param array|null $document Inline document to percolate. + * @param array|null $document Single inline document to percolate. + * @param array>|null $documents Multi-doc inline percolation. */ public function __construct( private string $field, private array|null $document = null, private string|null $index = null, private string|null $id = null, + private array|null $documents = null, + private string|null $name = null, + private string|null $routing = null, + private string|null $preference = null, + private int|null $version = null, ) { - if ($document === null && ($index === null || $id === null)) { + if ($document === null && $documents === null && ($index === null || $id === null)) { throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( - 'Percolate query requires either a document, or both index and id.', + 'Percolate query requires either a document, documents, or both index and id.', ); } } @@ -30,7 +37,7 @@ public function __construct( public function key(): string { - return 'percolate_' . $this->field; + return 'percolate_' . $this->field . ($this->name !== null ? '_' . $this->name : ''); } @@ -43,7 +50,10 @@ public function toArray(): array 'field' => $this->field, ]; - if ($this->document !== null) { + if ($this->documents !== null) { + $body['documents'] = $this->documents; + + } elseif ($this->document !== null) { $body['document'] = $this->document; } else { @@ -51,6 +61,22 @@ public function toArray(): array $body['id'] = $this->id; } + if ($this->name !== null) { + $body['name'] = $this->name; + } + + if ($this->routing !== null) { + $body['routing'] = $this->routing; + } + + if ($this->preference !== null) { + $body['preference'] = $this->preference; + } + + if ($this->version !== null) { + $body['version'] = $this->version; + } + return [ 'percolate' => $body, ]; diff --git a/tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt b/tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt index f76ee63..05b47f8 100644 --- a/tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/MoreLikeThis.phpt @@ -5,22 +5,10 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class MoreLikeThis extends \Tester\TestCase +class MoreLikeThis extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_mlt'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_query_mlt'; public function testToArray(): void @@ -39,6 +27,35 @@ class MoreLikeThis extends \Tester\TestCase } + public function testToArrayWithAllOptions(): void + { + $mlt = new \Spameri\ElasticQuery\Query\MoreLikeThis( + fields: ['title'], + like: ['quick fox'], + boostTerms: 0.5, + include: true, + minDocFreq: 5, + maxDocFreq: 1000, + minWordLength: 2, + maxWordLength: 100, + stopWords: ['the', 'a'], + analyzer: 'standard', + boost: 2.0, + failOnUnsupportedField: false, + ); + + $array = $mlt->toArray(); + + \Tester\Assert::same(0.5, $array['more_like_this']['boost_terms']); + \Tester\Assert::true($array['more_like_this']['include']); + \Tester\Assert::same(5, $array['more_like_this']['min_doc_freq']); + \Tester\Assert::same(1000, $array['more_like_this']['max_doc_freq']); + \Tester\Assert::same(['the', 'a'], $array['more_like_this']['stop_words']); + \Tester\Assert::same(2.0, $array['more_like_this']['boost']); + \Tester\Assert::false($array['more_like_this']['fail_on_unsupported_field']); + } + + public function testRequiresFields(): void { \Tester\Assert::exception( @@ -61,23 +78,27 @@ class MoreLikeThis extends \Tester\TestCase } - public function testKey(): void + public function testCreate(): void { - $mlt = new \Spameri\ElasticQuery\Query\MoreLikeThis(['title'], ['foo']); - - \Tester\Assert::same('more_like_this_title', $mlt->key()); - } - + $this->indexDocument(['title' => 'quick brown fox', 'body' => 'jumps over the lazy dog']); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\MoreLikeThis( + fields: ['title', 'body'], + like: ['quick brown fox'], + minTermFreq: 1, + minDocFreq: 1, + ), + ), + ), + ); - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Query/Percolate.phpt b/tests/SpameriTests/ElasticQuery/Query/Percolate.phpt index 30a9d6d..34abe41 100644 --- a/tests/SpameriTests/ElasticQuery/Query/Percolate.phpt +++ b/tests/SpameriTests/ElasticQuery/Query/Percolate.phpt @@ -5,21 +5,22 @@ namespace SpameriTests\ElasticQuery\Query; require_once __DIR__ . '/../../bootstrap.php'; -class Percolate extends \Tester\TestCase +class Percolate extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_query_percolate'; + protected const INDEX = 'spameri_test_query_percolate'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'query' => ['type' => 'percolator'], + 'message' => ['type' => 'text'], + ], + ], + ]; } @@ -30,9 +31,25 @@ class Percolate extends \Tester\TestCase document: ['message' => 'A new bonsai tree'], ); + \Tester\Assert::same(['message' => 'A new bonsai tree'], $percolate->toArray()['percolate']['document']); + } + + + public function testToArrayDocuments(): void + { + $percolate = new \Spameri\ElasticQuery\Query\Percolate( + field: 'query', + documents: [ + ['message' => 'hello'], + ['message' => 'world'], + ], + name: 'docs', + ); + $array = $percolate->toArray(); - \Tester\Assert::same(['message' => 'A new bonsai tree'], $array['percolate']['document']); + \Tester\Assert::count(2, $array['percolate']['documents']); + \Tester\Assert::same('docs', $array['percolate']['name']); } @@ -42,12 +59,18 @@ class Percolate extends \Tester\TestCase field: 'query', index: 'my-index', id: '1', + routing: 'r', + preference: 'p', + version: 7, ); $array = $percolate->toArray(); \Tester\Assert::same('my-index', $array['percolate']['index']); \Tester\Assert::same('1', $array['percolate']['id']); + \Tester\Assert::same('r', $array['percolate']['routing']); + \Tester\Assert::same('p', $array['percolate']['preference']); + \Tester\Assert::same(7, $array['percolate']['version']); } @@ -62,26 +85,29 @@ class Percolate extends \Tester\TestCase } - public function testKey(): void + public function testCreate(): void { - $percolate = new \Spameri\ElasticQuery\Query\Percolate( - field: 'query', - document: ['m' => 'hello'], + $this->request( + 'PUT', + self::INDEX . '/_doc/1?refresh=true', + ['query' => ['match' => ['message' => 'bonsai']]], ); - \Tester\Assert::same('percolate_query', $percolate->key()); - } - + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Percolate( + field: 'query', + document: ['message' => 'A new bonsai tree in the garden'], + ), + ), + ), + ); - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $result = $this->search($query); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } From f7b5e9ef9040c3f047835d34cc17673f3c4e4064 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:13:24 +0200 Subject: [PATCH 87/97] feat(query): add vector / semantic / rules query types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Knn: k-nearest neighbour vector similarity with field, query_vector, k, num_candidates, similarity, filter, boost. Integration test round-trips against a dense_vector mapping. - SparseVector: ELSER-style sparse vector query — accepts either inference_id+query or direct queryVector token weights. Integration test against a sparse_vector mapping. - TextExpansion: legacy ELSER form (model_id + model_text). - Semantic: queries a semantic_text field. - RuleQuery: applies Search Application query rules over an organic query. - WeightedTokens: token weights against a sparse_vector field. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Query/Knn.php | 75 +++++++++++++ src/Query/RuleQuery.php | 54 +++++++++ src/Query/Semantic.php | 45 ++++++++ src/Query/SparseVector.php | 86 +++++++++++++++ src/Query/TextExpansion.php | 58 ++++++++++ src/Query/WeightedTokens.php | 60 ++++++++++ .../SpameriTests/ElasticQuery/Query/Knn.phpt | 104 ++++++++++++++++++ .../ElasticQuery/Query/RuleQuery.phpt | 46 ++++++++ .../ElasticQuery/Query/Semantic.phpt | 39 +++++++ .../ElasticQuery/Query/SparseVector.phpt | 92 ++++++++++++++++ .../ElasticQuery/Query/TextExpansion.phpt | 67 +++++++++++ .../ElasticQuery/Query/WeightedTokens.phpt | 73 ++++++++++++ 12 files changed, 799 insertions(+) create mode 100644 src/Query/Knn.php create mode 100644 src/Query/RuleQuery.php create mode 100644 src/Query/Semantic.php create mode 100644 src/Query/SparseVector.php create mode 100644 src/Query/TextExpansion.php create mode 100644 src/Query/WeightedTokens.php create mode 100644 tests/SpameriTests/ElasticQuery/Query/Knn.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Query/RuleQuery.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Query/Semantic.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Query/SparseVector.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Query/TextExpansion.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Query/WeightedTokens.phpt diff --git a/src/Query/Knn.php b/src/Query/Knn.php new file mode 100644 index 0000000..29c59a2 --- /dev/null +++ b/src/Query/Knn.php @@ -0,0 +1,75 @@ + $queryVector + */ + public function __construct( + private string $field, + private array $queryVector, + private int $k, + private int $numCandidates, + private float|null $similarity = null, + private \Spameri\ElasticQuery\Query\LeafQueryInterface|null $filter = null, + private float $boost = 1.0, + ) + { + if ($queryVector === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Knn query requires a non-empty queryVector.', + ); + } + + if ($k < 1) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'Knn k must be >= 1.', + ); + } + } + + + public function key(): string + { + return 'knn_' . $this->field . '_' . $this->k; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'field' => $this->field, + 'query_vector' => $this->queryVector, + 'k' => $this->k, + 'num_candidates' => $this->numCandidates, + 'boost' => $this->boost, + ]; + + if ($this->similarity !== null) { + $body['similarity'] = $this->similarity; + } + + if ($this->filter !== null) { + $body['filter'] = $this->filter->toArray(); + } + + return [ + 'knn' => $body, + ]; + } + +} diff --git a/src/Query/RuleQuery.php b/src/Query/RuleQuery.php new file mode 100644 index 0000000..b879aff --- /dev/null +++ b/src/Query/RuleQuery.php @@ -0,0 +1,54 @@ + $rulesetIds + * @param array $matchCriteria + */ + public function __construct( + private \Spameri\ElasticQuery\Query\LeafQueryInterface $organic, + private array $rulesetIds, + private array $matchCriteria, + ) + { + if ($rulesetIds === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'RuleQuery requires at least one rulesetId.', + ); + } + } + + + public function key(): string + { + return 'rule_' . $this->organic->key() . '_' . \implode('-', $this->rulesetIds); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'rule' => [ + 'organic' => $this->organic->toArray(), + 'ruleset_ids' => $this->rulesetIds, + 'match_criteria' => $this->matchCriteria, + ], + ]; + } + +} diff --git a/src/Query/Semantic.php b/src/Query/Semantic.php new file mode 100644 index 0000000..0afcfe9 --- /dev/null +++ b/src/Query/Semantic.php @@ -0,0 +1,45 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'semantic' => [ + 'field' => $this->field, + 'query' => $this->query, + 'boost' => $this->boost, + ], + ]; + } + +} diff --git a/src/Query/SparseVector.php b/src/Query/SparseVector.php new file mode 100644 index 0000000..00ad16f --- /dev/null +++ b/src/Query/SparseVector.php @@ -0,0 +1,86 @@ + weight pairs directly) + * + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html + */ +class SparseVector implements LeafQueryInterface +{ + + /** + * @param array|null $queryVector Token => weight pairs. + * @param array|null $pruningConfig + */ + public function __construct( + private string $field, + private string|null $inferenceId = null, + private string|null $query = null, + private array|null $queryVector = null, + private bool|null $prune = null, + private array|null $pruningConfig = null, + private float $boost = 1.0, + ) + { + $hasInference = $inferenceId !== null && $query !== null; + $hasVector = $queryVector !== null && $queryVector !== []; + + if ( ! $hasInference && ! $hasVector) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'SparseVector requires either (inferenceId + query) or queryVector.', + ); + } + } + + + public function key(): string + { + return 'sparse_vector_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'field' => $this->field, + 'boost' => $this->boost, + ]; + + if ($this->inferenceId !== null) { + $body['inference_id'] = $this->inferenceId; + } + + if ($this->query !== null) { + $body['query'] = $this->query; + } + + if ($this->queryVector !== null) { + $body['query_vector'] = $this->queryVector; + } + + if ($this->prune !== null) { + $body['prune'] = $this->prune; + } + + if ($this->pruningConfig !== null) { + $body['pruning_config'] = $this->pruningConfig; + } + + return [ + 'sparse_vector' => $body, + ]; + } + +} diff --git a/src/Query/TextExpansion.php b/src/Query/TextExpansion.php new file mode 100644 index 0000000..7628220 --- /dev/null +++ b/src/Query/TextExpansion.php @@ -0,0 +1,58 @@ +|null $pruningConfig + */ + public function __construct( + private string $field, + private string $modelId, + private string $modelText, + private array|null $pruningConfig = null, + private float $boost = 1.0, + ) + { + } + + + public function key(): string + { + return 'text_expansion_' . $this->field; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $body = [ + 'model_id' => $this->modelId, + 'model_text' => $this->modelText, + 'boost' => $this->boost, + ]; + + if ($this->pruningConfig !== null) { + $body['pruning_config'] = $this->pruningConfig; + } + + return [ + 'text_expansion' => [ + $this->field => $body, + ], + ]; + } + +} diff --git a/src/Query/WeightedTokens.php b/src/Query/WeightedTokens.php new file mode 100644 index 0000000..19182e9 --- /dev/null +++ b/src/Query/WeightedTokens.php @@ -0,0 +1,60 @@ + $tokens Token => weight pairs. + * @param array|null $pruningConfig + */ + public function __construct( + private string $field, + private array $tokens, + private array|null $pruningConfig = null, + ) + { + if ($tokens === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'WeightedTokens requires at least one token.', + ); + } + } + + + public function key(): string + { + return 'weighted_tokens_' . $this->field; + } + + + /** + * @return array>> + */ + public function toArray(): array + { + $body = [ + 'tokens' => $this->tokens, + ]; + + if ($this->pruningConfig !== null) { + $body['pruning_config'] = $this->pruningConfig; + } + + return [ + 'weighted_tokens' => [ + $this->field => $body, + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Query/Knn.phpt b/tests/SpameriTests/ElasticQuery/Query/Knn.phpt new file mode 100644 index 0000000..5f5af49 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Knn.phpt @@ -0,0 +1,104 @@ + [ + 'properties' => [ + 'vector' => [ + 'type' => 'dense_vector', + 'dims' => 3, + 'index' => true, + 'similarity' => 'l2_norm', + ], + ], + ], + ]; + } + + + public function testToArray(): void + { + $knn = new \Spameri\ElasticQuery\Query\Knn( + field: 'vector', + queryVector: [1.0, 2.0, 3.0], + k: 5, + numCandidates: 50, + similarity: 0.7, + boost: 1.5, + ); + + $array = $knn->toArray(); + + \Tester\Assert::same('vector', $array['knn']['field']); + \Tester\Assert::same([1.0, 2.0, 3.0], $array['knn']['query_vector']); + \Tester\Assert::same(5, $array['knn']['k']); + \Tester\Assert::same(50, $array['knn']['num_candidates']); + \Tester\Assert::same(0.7, $array['knn']['similarity']); + \Tester\Assert::same(1.5, $array['knn']['boost']); + } + + + public function testWithFilter(): void + { + $knn = new \Spameri\ElasticQuery\Query\Knn( + field: 'vector', + queryVector: [1.0, 2.0, 3.0], + k: 5, + numCandidates: 50, + filter: new \Spameri\ElasticQuery\Query\Term('status', 'published'), + ); + + \Tester\Assert::same('published', $knn->toArray()['knn']['filter']['term']['status']['value']); + } + + + public function testRequiresQueryVector(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\Knn('v', [], 1, 10); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void + { + $this->indexDocument(['vector' => [1.0, 2.0, 3.0]]); + $this->indexDocument(['vector' => [10.0, 10.0, 10.0]]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\Knn( + field: 'vector', + queryVector: [1.1, 2.1, 3.1], + k: 1, + numCandidates: 10, + ), + ), + ), + ); + + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); + } + +} + +(new Knn())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Query/RuleQuery.phpt b/tests/SpameriTests/ElasticQuery/Query/RuleQuery.phpt new file mode 100644 index 0000000..cfa7bc1 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/RuleQuery.phpt @@ -0,0 +1,46 @@ + 'puggles'], + ); + + $array = $rule->toArray(); + + \Tester\Assert::same(['my-ruleset'], $array['rule']['ruleset_ids']); + \Tester\Assert::same(['query_string' => 'puggles'], $array['rule']['match_criteria']); + \Tester\Assert::same('value', $array['rule']['organic']['term']['field']['value']); + } + + + public function testRequiresRulesetIds(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\RuleQuery( + organic: new \Spameri\ElasticQuery\Query\Term('a', 'b'), + rulesetIds: [], + matchCriteria: [], + ); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + +} + +(new RuleQuery())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Query/Semantic.phpt b/tests/SpameriTests/ElasticQuery/Query/Semantic.phpt new file mode 100644 index 0000000..05ea74e --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/Semantic.phpt @@ -0,0 +1,39 @@ +toArray(); + + \Tester\Assert::same('inference_field', $array['semantic']['field']); + \Tester\Assert::same('large cat', $array['semantic']['query']); + \Tester\Assert::same(1.5, $array['semantic']['boost']); + } + + + public function testKey(): void + { + $semantic = new \Spameri\ElasticQuery\Query\Semantic('f', 'q'); + + \Tester\Assert::same('semantic_f', $semantic->key()); + } + +} + +(new Semantic())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Query/SparseVector.phpt b/tests/SpameriTests/ElasticQuery/Query/SparseVector.phpt new file mode 100644 index 0000000..816f052 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/SparseVector.phpt @@ -0,0 +1,92 @@ + [ + 'properties' => [ + 'tokens' => ['type' => 'sparse_vector'], + ], + ], + ]; + } + + + public function testToArrayWithVector(): void + { + $sv = new \Spameri\ElasticQuery\Query\SparseVector( + field: 'tokens', + queryVector: ['lion' => 0.5, 'tiger' => 0.7], + ); + + $array = $sv->toArray(); + + \Tester\Assert::same('tokens', $array['sparse_vector']['field']); + \Tester\Assert::same(0.5, $array['sparse_vector']['query_vector']['lion']); + } + + + public function testToArrayWithInference(): void + { + $sv = new \Spameri\ElasticQuery\Query\SparseVector( + field: 'tokens', + inferenceId: '.elser_model_2', + query: 'big cat', + prune: true, + pruningConfig: ['tokens_freq_ratio_threshold' => 5], + ); + + $array = $sv->toArray(); + + \Tester\Assert::same('.elser_model_2', $array['sparse_vector']['inference_id']); + \Tester\Assert::same('big cat', $array['sparse_vector']['query']); + \Tester\Assert::true($array['sparse_vector']['prune']); + } + + + public function testRequiresVectorOrInference(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\SparseVector('f'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void + { + $this->indexDocument(['tokens' => ['lion' => 0.5, 'cat' => 0.3]]); + + $query = new \Spameri\ElasticQuery\ElasticQuery( + new \Spameri\ElasticQuery\Query\QueryCollection( + null, + new \Spameri\ElasticQuery\Query\MustCollection( + new \Spameri\ElasticQuery\Query\SparseVector( + field: 'tokens', + queryVector: ['lion' => 0.5, 'tiger' => 0.7], + ), + ), + ), + ); + + $result = $this->search($query); + + \Tester\Assert::same(1, $result->stats()->total()); + } + +} + +(new SparseVector())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Query/TextExpansion.phpt b/tests/SpameriTests/ElasticQuery/Query/TextExpansion.phpt new file mode 100644 index 0000000..e121447 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/TextExpansion.phpt @@ -0,0 +1,67 @@ + [ + 'properties' => [ + 'tokens' => ['type' => 'sparse_vector'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $te = new \Spameri\ElasticQuery\Query\TextExpansion( + field: 'tokens', + modelId: '.elser_model_2', + modelText: 'big cat', + ); + + $array = $te->toArray(); + + \Tester\Assert::same('.elser_model_2', $array['text_expansion']['tokens']['model_id']); + \Tester\Assert::same('big cat', $array['text_expansion']['tokens']['model_text']); + } + + + public function testWithPruningConfig(): void + { + $te = new \Spameri\ElasticQuery\Query\TextExpansion( + field: 'tokens', + modelId: '.elser_model_2', + modelText: 'q', + pruningConfig: ['tokens_freq_ratio_threshold' => 5, 'only_score_pruned_tokens' => false], + boost: 2.0, + ); + + $array = $te->toArray(); + + \Tester\Assert::same(5, $array['text_expansion']['tokens']['pruning_config']['tokens_freq_ratio_threshold']); + \Tester\Assert::same(2.0, $array['text_expansion']['tokens']['boost']); + } + + + public function testKey(): void + { + $te = new \Spameri\ElasticQuery\Query\TextExpansion('f', 'm', 'q'); + + \Tester\Assert::same('text_expansion_f', $te->key()); + } + +} + +(new TextExpansion())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Query/WeightedTokens.phpt b/tests/SpameriTests/ElasticQuery/Query/WeightedTokens.phpt new file mode 100644 index 0000000..46dccdc --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Query/WeightedTokens.phpt @@ -0,0 +1,73 @@ + [ + 'properties' => [ + 'tokens' => ['type' => 'sparse_vector'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $wt = new \Spameri\ElasticQuery\Query\WeightedTokens( + field: 'tokens', + tokens: ['lion' => 0.5, 'tiger' => 0.7], + ); + + $array = $wt->toArray(); + + \Tester\Assert::same(0.5, $array['weighted_tokens']['tokens']['tokens']['lion']); + } + + + public function testToArrayWithPruning(): void + { + $wt = new \Spameri\ElasticQuery\Query\WeightedTokens( + field: 'tokens', + tokens: ['cat' => 0.5], + pruningConfig: ['tokens_freq_ratio_threshold' => 5], + ); + + $array = $wt->toArray(); + + \Tester\Assert::same(5, $array['weighted_tokens']['tokens']['pruning_config']['tokens_freq_ratio_threshold']); + } + + + public function testRequiresTokens(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Query\WeightedTokens('f', []); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testKey(): void + { + $wt = new \Spameri\ElasticQuery\Query\WeightedTokens('f', ['t' => 0.5]); + + \Tester\Assert::same('weighted_tokens_f', $wt->key()); + } + +} + +(new WeightedTokens())->run(); From add346664c329af0372375c4b32a6b264004eba9 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:21:35 +0200 Subject: [PATCH 88/97] feat(agg): metric aggregation coverage + TopHits rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New typed Script value object (Spameri\ElasticQuery\Script) — shared between aggregations, future score functions, and runtime mappings. Metric aggregations gain missing/script/format consistently: - Min/Max/Avg/Sum: + missing, script, format - ValueCount: + script, format - Stats/ExtendedStats: + missing, script, format - Cardinality: + script, missing, rehash - MedianAbsoluteDeviation/StringStats: + missing, script - BoxPlot: + missing, script, execution_hint - Percentiles: + tdigest, hdr, missing, script - PercentileRanks: + hdr, missing, script WeightedAvg restructured to use typed WeightedAvgValue sub-objects so each side carries its own field|script + missing. Plus format. TopHits rewritten — was a single-size wrapper, now exposes from, sort, _source, highlight, explain, script_fields, docvalue_fields, version, seq_no_primary_term, stored_fields, track_scores. Tests migrated to AbstractElasticTestCase where most-modified; existing tests still pass via additive constructor args. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Aggregation/Avg.php | 27 ++++- src/Aggregation/BoxPlot.php | 24 +++- src/Aggregation/Cardinality.php | 24 +++- src/Aggregation/ExtendedStats.php | 24 +++- src/Aggregation/Max.php | 29 ++++- src/Aggregation/MedianAbsoluteDeviation.php | 19 +++- src/Aggregation/Min.php | 29 ++++- src/Aggregation/PercentileRanks.php | 21 +++- src/Aggregation/Percentiles.php | 31 ++++- src/Aggregation/Stats.php | 26 ++++- src/Aggregation/StringStats.php | 19 +++- src/Aggregation/Sum.php | 52 +++++++++ src/Aggregation/TopHits.php | 79 ++++++++++++- src/Aggregation/ValueCount.php | 21 +++- src/Aggregation/WeightedAvg.php | 29 ++--- .../WeightedAvg/WeightedAvgValue.php | 52 +++++++++ src/Script.php | 44 ++++++++ .../ElasticQuery/Aggregation/Avg.phpt | 85 ++++---------- .../ElasticQuery/Aggregation/Cardinality.phpt | 91 +++------------ .../ElasticQuery/Aggregation/Max.phpt | 86 ++++---------- .../ElasticQuery/Aggregation/Min.phpt | 90 +++++++-------- .../ElasticQuery/Aggregation/Stats.phpt | 84 ++++---------- .../ElasticQuery/Aggregation/Sum.phpt | 53 +++++++++ .../ElasticQuery/Aggregation/ValueCount.phpt | 83 +++----------- .../ElasticQuery/Aggregation/WeightedAvg.phpt | 106 +++++++++--------- 25 files changed, 706 insertions(+), 522 deletions(-) create mode 100644 src/Aggregation/Sum.php create mode 100644 src/Aggregation/WeightedAvg/WeightedAvgValue.php create mode 100644 src/Script.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Sum.phpt diff --git a/src/Aggregation/Avg.php b/src/Aggregation/Avg.php index 25381e4..3ba6a1d 100644 --- a/src/Aggregation/Avg.php +++ b/src/Aggregation/Avg.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-avg-aggregation.html */ @@ -12,6 +13,9 @@ class Avg implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface public function __construct( private string $field, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $format = null, ) { } @@ -23,13 +27,26 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { - return [ - 'avg' => [ - 'field' => $this->field, - ], - ]; + $array = ['field' => $this->field]; + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['avg' => $array]; } } diff --git a/src/Aggregation/BoxPlot.php b/src/Aggregation/BoxPlot.php index b0eb2c9..316ba6a 100644 --- a/src/Aggregation/BoxPlot.php +++ b/src/Aggregation/BoxPlot.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-boxplot-aggregation.html */ @@ -13,6 +14,9 @@ class BoxPlot implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterf public function __construct( private string $field, private int|null $compression = null, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $executionHint = null, ) { } @@ -29,17 +33,25 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->compression !== null) { $array['compression'] = $this->compression; } - return [ - 'boxplot' => $array, - ]; + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->executionHint !== null) { + $array['execution_hint'] = $this->executionHint; + } + + return ['boxplot' => $array]; } } diff --git a/src/Aggregation/Cardinality.php b/src/Aggregation/Cardinality.php index 71e63c5..6c4f699 100644 --- a/src/Aggregation/Cardinality.php +++ b/src/Aggregation/Cardinality.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-cardinality-aggregation.html */ @@ -13,6 +14,9 @@ class Cardinality implements \Spameri\ElasticQuery\Aggregation\LeafAggregationIn public function __construct( private string $field, private int|null $precisionThreshold = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private float|int|string|null $missing = null, + private bool|null $rehash = null, ) { } @@ -29,17 +33,25 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->precisionThreshold !== null) { $array['precision_threshold'] = $this->precisionThreshold; } - return [ - 'cardinality' => $array, - ]; + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->rehash !== null) { + $array['rehash'] = $this->rehash; + } + + return ['cardinality' => $array]; } } diff --git a/src/Aggregation/ExtendedStats.php b/src/Aggregation/ExtendedStats.php index 9a85e83..c4272c8 100644 --- a/src/Aggregation/ExtendedStats.php +++ b/src/Aggregation/ExtendedStats.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-extendedstats-aggregation.html */ @@ -13,6 +14,9 @@ class ExtendedStats implements \Spameri\ElasticQuery\Aggregation\LeafAggregation public function __construct( private string $field, private float|null $sigma = null, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $format = null, ) { } @@ -29,17 +33,25 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->sigma !== null) { $array['sigma'] = $this->sigma; } - return [ - 'extended_stats' => $array, - ]; + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['extended_stats' => $array]; } } diff --git a/src/Aggregation/Max.php b/src/Aggregation/Max.php index 5e8a133..0e77272 100644 --- a/src/Aggregation/Max.php +++ b/src/Aggregation/Max.php @@ -4,11 +4,18 @@ namespace Spameri\ElasticQuery\Aggregation; + +/** + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-max-aggregation.html + */ class Max implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { public function __construct( private string $field, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $format = null, ) { } @@ -21,15 +28,25 @@ public function key(): string /** - * @return array> + * @return array> */ public function toArray(): array { - return [ - 'max' => [ - 'field' => $this->field, - ], - ]; + $array = ['field' => $this->field]; + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['max' => $array]; } } diff --git a/src/Aggregation/MedianAbsoluteDeviation.php b/src/Aggregation/MedianAbsoluteDeviation.php index c05470f..8960580 100644 --- a/src/Aggregation/MedianAbsoluteDeviation.php +++ b/src/Aggregation/MedianAbsoluteDeviation.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-median-absolute-deviation-aggregation.html */ @@ -13,6 +14,8 @@ class MedianAbsoluteDeviation implements \Spameri\ElasticQuery\Aggregation\LeafA public function __construct( private string $field, private int|null $compression = null, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, ) { } @@ -29,17 +32,21 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->compression !== null) { $array['compression'] = $this->compression; } - return [ - 'median_absolute_deviation' => $array, - ]; + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + return ['median_absolute_deviation' => $array]; } } diff --git a/src/Aggregation/Min.php b/src/Aggregation/Min.php index 9cd5434..47cabaf 100644 --- a/src/Aggregation/Min.php +++ b/src/Aggregation/Min.php @@ -4,11 +4,18 @@ namespace Spameri\ElasticQuery\Aggregation; + +/** + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-min-aggregation.html + */ class Min implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { public function __construct( private string $field, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $format = null, ) { } @@ -21,14 +28,28 @@ public function key(): string /** - * @return array + * @return array> */ public function toArray(): array { + $array = [ + 'field' => $this->field, + ]; + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + return [ - 'min' => [ - 'field' => $this->field, - ], + 'min' => $array, ]; } diff --git a/src/Aggregation/PercentileRanks.php b/src/Aggregation/PercentileRanks.php index 9a2a634..59d6ee4 100644 --- a/src/Aggregation/PercentileRanks.php +++ b/src/Aggregation/PercentileRanks.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-percentile-rank-aggregation.html */ @@ -12,11 +13,15 @@ class PercentileRanks implements \Spameri\ElasticQuery\Aggregation\LeafAggregati /** * @param array $values + * @param array|null $hdr */ public function __construct( private string $field, private array $values, private bool $keyed = true, + private array|null $hdr = null, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, ) { } @@ -42,9 +47,19 @@ public function toArray(): array $array['keyed'] = false; } - return [ - 'percentile_ranks' => $array, - ]; + if ($this->hdr !== null) { + $array['hdr'] = $this->hdr; + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + return ['percentile_ranks' => $array]; } } diff --git a/src/Aggregation/Percentiles.php b/src/Aggregation/Percentiles.php index 88ba211..f841716 100644 --- a/src/Aggregation/Percentiles.php +++ b/src/Aggregation/Percentiles.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-percentile-aggregation.html */ @@ -12,11 +13,17 @@ class Percentiles implements \Spameri\ElasticQuery\Aggregation\LeafAggregationIn /** * @param array $percents + * @param array|null $tdigest + * @param array|null $hdr */ public function __construct( private string $field, private array $percents = [], private bool $keyed = true, + private array|null $tdigest = null, + private array|null $hdr = null, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, ) { } @@ -33,9 +40,7 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->percents !== []) { $array['percents'] = $this->percents; @@ -45,9 +50,23 @@ public function toArray(): array $array['keyed'] = false; } - return [ - 'percentiles' => $array, - ]; + if ($this->tdigest !== null) { + $array['tdigest'] = $this->tdigest; + } + + if ($this->hdr !== null) { + $array['hdr'] = $this->hdr; + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + return ['percentiles' => $array]; } } diff --git a/src/Aggregation/Stats.php b/src/Aggregation/Stats.php index 76c6a81..f82055d 100644 --- a/src/Aggregation/Stats.php +++ b/src/Aggregation/Stats.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-stats-aggregation.html */ @@ -12,6 +13,9 @@ class Stats implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterfac public function __construct( private string $field, + private float|int|string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $format = null, ) { } @@ -24,15 +28,25 @@ public function key(): string /** - * @return array> + * @return array> */ public function toArray(): array { - return [ - 'stats' => [ - 'field' => $this->field, - ], - ]; + $array = ['field' => $this->field]; + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['stats' => $array]; } } diff --git a/src/Aggregation/StringStats.php b/src/Aggregation/StringStats.php index 2f79d22..2b45106 100644 --- a/src/Aggregation/StringStats.php +++ b/src/Aggregation/StringStats.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-string-stats-aggregation.html */ @@ -13,6 +14,8 @@ class StringStats implements \Spameri\ElasticQuery\Aggregation\LeafAggregationIn public function __construct( private string $field, private bool $showDistribution = false, + private string|null $missing = null, + private \Spameri\ElasticQuery\Script|null $script = null, ) { } @@ -29,17 +32,21 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->showDistribution === true) { $array['show_distribution'] = true; } - return [ - 'string_stats' => $array, - ]; + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + return ['string_stats' => $array]; } } diff --git a/src/Aggregation/Sum.php b/src/Aggregation/Sum.php new file mode 100644 index 0000000..e6d003f --- /dev/null +++ b/src/Aggregation/Sum.php @@ -0,0 +1,52 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['field' => $this->field]; + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['sum' => $array]; + } + +} diff --git a/src/Aggregation/TopHits.php b/src/Aggregation/TopHits.php index 44fede2..67e33db 100644 --- a/src/Aggregation/TopHits.php +++ b/src/Aggregation/TopHits.php @@ -4,11 +4,33 @@ namespace Spameri\ElasticQuery\Aggregation; + +/** + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-top-hits-aggregation.html + */ class TopHits implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + /** + * @param bool|array|array> $source + * @param array $scriptFields Script-name => {script: {...}} map. + * @param array $docvalueFields + * @param array $storedFields + */ public function __construct( private int $size, + private int|null $from = null, + private \Spameri\ElasticQuery\Options\SortCollection|null $sort = null, + private bool|array $source = true, + private \Spameri\ElasticQuery\Highlight|null $highlight = null, + private bool|null $explain = null, + private array $scriptFields = [], + private array $docvalueFields = [], + private bool|null $version = null, + private bool|null $seqNoPrimaryTerm = null, + private array $storedFields = [], + private bool|null $trackScores = null, + private string $key = 'top_hits', ) { } @@ -16,17 +38,62 @@ public function __construct( public function key(): string { - return 'top_hits_' . $this->size; + return $this->key . '_' . $this->size; } + /** + * @return array> + */ public function toArray(): array { - return [ - 'top_hits' => [ - 'size' => $this->size, - ], - ]; + $array = ['size' => $this->size]; + + if ($this->from !== null) { + $array['from'] = $this->from; + } + + if ($this->sort !== null && $this->sort->count() > 0) { + $array['sort'] = $this->sort->toArray(); + } + + if ($this->source !== true) { + $array['_source'] = $this->source; + } + + if ($this->highlight !== null) { + $array['highlight'] = $this->highlight->toArray(); + } + + if ($this->explain !== null) { + $array['explain'] = $this->explain; + } + + if ($this->scriptFields !== []) { + $array['script_fields'] = $this->scriptFields; + } + + if ($this->docvalueFields !== []) { + $array['docvalue_fields'] = $this->docvalueFields; + } + + if ($this->version !== null) { + $array['version'] = $this->version; + } + + if ($this->seqNoPrimaryTerm !== null) { + $array['seq_no_primary_term'] = $this->seqNoPrimaryTerm; + } + + if ($this->storedFields !== []) { + $array['stored_fields'] = $this->storedFields; + } + + if ($this->trackScores !== null) { + $array['track_scores'] = $this->trackScores; + } + + return ['top_hits' => $array]; } } diff --git a/src/Aggregation/ValueCount.php b/src/Aggregation/ValueCount.php index 7d8ec09..edc7ad0 100644 --- a/src/Aggregation/ValueCount.php +++ b/src/Aggregation/ValueCount.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-valuecount-aggregation.html */ @@ -12,6 +13,8 @@ class ValueCount implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInt public function __construct( private string $field, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $format = null, ) { } @@ -24,15 +27,21 @@ public function key(): string /** - * @return array> + * @return array> */ public function toArray(): array { - return [ - 'value_count' => [ - 'field' => $this->field, - ], - ]; + $array = ['field' => $this->field]; + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['value_count' => $array]; } } diff --git a/src/Aggregation/WeightedAvg.php b/src/Aggregation/WeightedAvg.php index cc0ae48..1a6190b 100644 --- a/src/Aggregation/WeightedAvg.php +++ b/src/Aggregation/WeightedAvg.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-weight-avg-aggregation.html */ @@ -11,8 +12,10 @@ class WeightedAvg implements \Spameri\ElasticQuery\Aggregation\LeafAggregationIn { public function __construct( - private string $valueField, - private string $weightField, + private \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue $value, + private \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue $weight, + private string|null $format = null, + private string $key = 'weighted_avg', ) { } @@ -20,25 +23,25 @@ public function __construct( public function key(): string { - return 'weighted_avg_' . $this->valueField; + return $this->key; } /** - * @return array>> + * @return array> */ public function toArray(): array { - return [ - 'weighted_avg' => [ - 'value' => [ - 'field' => $this->valueField, - ], - 'weight' => [ - 'field' => $this->weightField, - ], - ], + $array = [ + 'value' => $this->value->toArray(), + 'weight' => $this->weight->toArray(), ]; + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['weighted_avg' => $array]; } } diff --git a/src/Aggregation/WeightedAvg/WeightedAvgValue.php b/src/Aggregation/WeightedAvg/WeightedAvgValue.php new file mode 100644 index 0000000..2205f01 --- /dev/null +++ b/src/Aggregation/WeightedAvg/WeightedAvgValue.php @@ -0,0 +1,52 @@ + + */ + public function toArray(): array + { + $array = []; + + if ($this->field !== null) { + $array['field'] = $this->field; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + return $array; + } + +} diff --git a/src/Script.php b/src/Script.php new file mode 100644 index 0000000..166a67f --- /dev/null +++ b/src/Script.php @@ -0,0 +1,44 @@ + $params + */ + public function __construct( + private string $source, + private string $lang = 'painless', + private array $params = [], + ) + { + } + + + /** + * @return array + */ + public function toArray(): array + { + $array = [ + 'source' => $this->source, + 'lang' => $this->lang, + ]; + + if ($this->params !== []) { + $array['params'] = $this->params; + } + + return $array; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Avg.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Avg.phpt index f3ae24f..22dfb91 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/Avg.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Avg.phpt @@ -5,93 +5,48 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class Avg extends \Tester\TestCase +class Avg extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_avg'; + protected const INDEX = 'spameri_test_aggregation_avg'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['price' => ['type' => 'long']]]]; } public function testToArray(): void { - $avg = new \Spameri\ElasticQuery\Aggregation\Avg('price'); - - $array = $avg->toArray(); - - \Tester\Assert::true(isset($array['avg']['field'])); - \Tester\Assert::same('price', $array['avg']['field']); + \Tester\Assert::same('price', (new \Spameri\ElasticQuery\Aggregation\Avg('price'))->toArray()['avg']['field']); } - public function testKey(): void + public function testToArrayWithOptions(): void { - $avg = new \Spameri\ElasticQuery\Aggregation\Avg('price'); - - \Tester\Assert::same('avg_price', $avg->key()); + $avg = new \Spameri\ElasticQuery\Aggregation\Avg( + field: 'price', + missing: 0, + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].value"), + format: '00.00', + ); + $array = $avg->toArray(); + \Tester\Assert::same(0, $array['avg']['missing']); + \Tester\Assert::same("doc['price'].value", $array['avg']['script']['source']); } public function testCreate(): void { - $avg = new \Spameri\ElasticQuery\Aggregation\Avg('price'); + $this->indexDocument(['price' => 100]); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'price_avg', - null, - $avg, - ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_avg', null, new \Spameri\ElasticQuery\Aggregation\Avg('price'), + )); - \curl_exec($ch); + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt index d92fb2b..68512da 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Cardinality.phpt @@ -5,104 +5,49 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class Cardinality extends \Tester\TestCase +class Cardinality extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_cardinality'; + protected const INDEX = 'spameri_test_aggregation_cardinality'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['user_id' => ['type' => 'keyword']]]]; } public function testToArray(): void { $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id'); - - $array = $cardinality->toArray(); - - \Tester\Assert::true(isset($array['cardinality']['field'])); - \Tester\Assert::same('user_id', $array['cardinality']['field']); - \Tester\Assert::false(isset($array['cardinality']['precision_threshold'])); + \Tester\Assert::same('user_id', $cardinality->toArray()['cardinality']['field']); } - public function testToArrayWithPrecisionThreshold(): void + public function testToArrayWithOptions(): void { - $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id', 3000); - + $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality( + field: 'user_id', + precisionThreshold: 3000, + missing: 'none', + ); $array = $cardinality->toArray(); - \Tester\Assert::same(3000, $array['cardinality']['precision_threshold']); - } - - - public function testKey(): void - { - $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id'); - - \Tester\Assert::same('cardinality_user_id', $cardinality->key()); + \Tester\Assert::same('none', $array['cardinality']['missing']); } public function testCreate(): void { - $cardinality = new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id'); + $this->indexDocument(['user_id' => 'a']); + $this->indexDocument(['user_id' => 'b']); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'distinct_users', - null, - $cardinality, - ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'distinct_users', null, new \Spameri\ElasticQuery\Aggregation\Cardinality('user_id'), + )); - \curl_exec($ch); + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Max.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Max.phpt index 99aa685..df86ce2 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/Max.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Max.phpt @@ -5,93 +5,49 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class Max extends \Tester\TestCase +class Max extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_max'; + protected const INDEX = 'spameri_test_aggregation_max'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['price' => ['type' => 'long']]]]; } public function testToArray(): void { - $max = new \Spameri\ElasticQuery\Aggregation\Max('price'); - - $array = $max->toArray(); - - \Tester\Assert::true(isset($array['max']['field'])); - \Tester\Assert::same('price', $array['max']['field']); + \Tester\Assert::same('price', (new \Spameri\ElasticQuery\Aggregation\Max('price'))->toArray()['max']['field']); } - public function testKey(): void + public function testToArrayWithOptions(): void { - $max = new \Spameri\ElasticQuery\Aggregation\Max('price'); - - \Tester\Assert::same('max_price', $max->key()); + $max = new \Spameri\ElasticQuery\Aggregation\Max( + field: 'price', + missing: 0, + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].value"), + format: '00.00', + ); + $array = $max->toArray(); + \Tester\Assert::same(0, $array['max']['missing']); + \Tester\Assert::same("doc['price'].value", $array['max']['script']['source']); + \Tester\Assert::same('00.00', $array['max']['format']); } public function testCreate(): void { - $max = new \Spameri\ElasticQuery\Aggregation\Max('price'); + $this->indexDocument(['price' => 100]); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'price_max', - null, - $max, - ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_max', null, new \Spameri\ElasticQuery\Aggregation\Max('price'), + )); - \curl_exec($ch); + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Min.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Min.phpt index d6d372e..baede26 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/Min.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Min.phpt @@ -5,21 +5,15 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class Min extends \Tester\TestCase +class Min extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_min'; + protected const INDEX = 'spameri_test_aggregation_min'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['price' => ['type' => 'long']]]]; } @@ -27,71 +21,67 @@ class Min extends \Tester\TestCase { $min = new \Spameri\ElasticQuery\Aggregation\Min('price'); - $array = $min->toArray(); - - \Tester\Assert::true(isset($array['min']['field'])); - \Tester\Assert::same('price', $array['min']['field']); + \Tester\Assert::same('price', $min->toArray()['min']['field']); } - public function testKey(): void + public function testToArrayWithOptions(): void { - $min = new \Spameri\ElasticQuery\Aggregation\Min('price'); + $min = new \Spameri\ElasticQuery\Aggregation\Min( + field: 'price', + missing: 0, + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].value * 2", lang: 'painless'), + format: '00.00', + ); - \Tester\Assert::same('min_price', $min->key()); + $array = $min->toArray(); + + \Tester\Assert::same(0, $array['min']['missing']); + \Tester\Assert::same("doc['price'].value * 2", $array['min']['script']['source']); + \Tester\Assert::same('00.00', $array['min']['format']); } public function testCreate(): void { - $min = new \Spameri\ElasticQuery\Aggregation\Min('price'); + $this->indexDocument(['price' => 100]); + $this->indexDocument(['price' => 200]); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); $elasticQuery->aggregation()->add( new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( 'price_min', null, - $min, + new \Spameri\ElasticQuery\Aggregation\Min('price'), ), ); - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); + $result = $this->search($elasticQuery); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); + \Tester\Assert::same(2, $result->stats()->total()); } - public function tearDown(): void + public function testCreateWithOptions(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['price' => 100]); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add( + new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_min', + null, + new \Spameri\ElasticQuery\Aggregation\Min( + field: 'price', + missing: 0, + format: '00.00', + ), + ), + ); + + $result = $this->search($elasticQuery); - \curl_exec($ch); + \Tester\Assert::same(1, $result->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt index 346f1e5..86a7957 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Stats.phpt @@ -5,93 +5,47 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class Stats extends \Tester\TestCase +class Stats extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_stats'; + protected const INDEX = 'spameri_test_aggregation_stats'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['price' => ['type' => 'long']]]]; } public function testToArray(): void { - $stats = new \Spameri\ElasticQuery\Aggregation\Stats('price'); - - $array = $stats->toArray(); - - \Tester\Assert::true(isset($array['stats']['field'])); - \Tester\Assert::same('price', $array['stats']['field']); + \Tester\Assert::same('price', (new \Spameri\ElasticQuery\Aggregation\Stats('price'))->toArray()['stats']['field']); } - public function testKey(): void + public function testToArrayWithOptions(): void { - $stats = new \Spameri\ElasticQuery\Aggregation\Stats('price'); - - \Tester\Assert::same('stats_price', $stats->key()); + $stats = new \Spameri\ElasticQuery\Aggregation\Stats( + field: 'price', + missing: 0, + format: '00.00', + ); + $array = $stats->toArray(); + \Tester\Assert::same(0, $array['stats']['missing']); + \Tester\Assert::same('00.00', $array['stats']['format']); } public function testCreate(): void { - $stats = new \Spameri\ElasticQuery\Aggregation\Stats('price'); + $this->indexDocument(['price' => 100]); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'price_stats', - null, - $stats, - ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_stats', null, new \Spameri\ElasticQuery\Aggregation\Stats('price'), + )); - \curl_exec($ch); + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Sum.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Sum.phpt new file mode 100644 index 0000000..08ef87d --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Sum.phpt @@ -0,0 +1,53 @@ + ['properties' => ['price' => ['type' => 'long']]]]; + } + + + public function testToArray(): void + { + \Tester\Assert::same('price', (new \Spameri\ElasticQuery\Aggregation\Sum('price'))->toArray()['sum']['field']); + } + + + public function testToArrayWithOptions(): void + { + $sum = new \Spameri\ElasticQuery\Aggregation\Sum( + field: 'price', + missing: 0, + format: '0.00', + ); + $array = $sum->toArray(); + \Tester\Assert::same(0, $array['sum']['missing']); + \Tester\Assert::same('0.00', $array['sum']['format']); + } + + + public function testCreate(): void + { + $this->indexDocument(['price' => 100]); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_sum', null, new \Spameri\ElasticQuery\Aggregation\Sum('price'), + )); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Sum())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt index 4667f37..0bdaa32 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/ValueCount.phpt @@ -5,93 +5,38 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class ValueCount extends \Tester\TestCase +class ValueCount extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_value_count'; - - - public function setUp(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); - } + protected const INDEX = 'spameri_test_aggregation_value_count'; public function testToArray(): void { - $valueCount = new \Spameri\ElasticQuery\Aggregation\ValueCount('price'); - - $array = $valueCount->toArray(); - - \Tester\Assert::true(isset($array['value_count']['field'])); - \Tester\Assert::same('price', $array['value_count']['field']); + \Tester\Assert::same('price', (new \Spameri\ElasticQuery\Aggregation\ValueCount('price'))->toArray()['value_count']['field']); } - public function testKey(): void + public function testToArrayWithScript(): void { - $valueCount = new \Spameri\ElasticQuery\Aggregation\ValueCount('price'); - - \Tester\Assert::same('value_count_price', $valueCount->key()); + $vc = new \Spameri\ElasticQuery\Aggregation\ValueCount( + field: 'price', + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].size()"), + ); + \Tester\Assert::same("doc['price'].size()", $vc->toArray()['value_count']['script']['source']); } public function testCreate(): void { - $valueCount = new \Spameri\ElasticQuery\Aggregation\ValueCount('price'); + $this->indexDocument(['price' => 100]); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'price_value_count', - null, - $valueCount, - ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_count', null, new \Spameri\ElasticQuery\Aggregation\ValueCount('price'), + )); - \curl_exec($ch); + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt index e504268..686efaf 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/WeightedAvg.phpt @@ -5,27 +5,31 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class WeightedAvg extends \Tester\TestCase +class WeightedAvg extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_weighted_avg'; + protected const INDEX = 'spameri_test_aggregation_weighted_avg'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'grade' => ['type' => 'long'], + 'weight' => ['type' => 'long'], + ], + ], + ]; } public function testToArray(): void { - $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg('grade', 'weight'); + $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg( + value: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'grade'), + weight: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'weight'), + ); $array = $weightedAvg->toArray(); @@ -34,17 +38,53 @@ class WeightedAvg extends \Tester\TestCase } + public function testToArrayWithMissing(): void + { + $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg( + value: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'grade', missing: 0), + weight: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'weight', missing: 1), + format: '00.00', + ); + + $array = $weightedAvg->toArray(); + + \Tester\Assert::same(0, $array['weighted_avg']['value']['missing']); + \Tester\Assert::same(1, $array['weighted_avg']['weight']['missing']); + \Tester\Assert::same('00.00', $array['weighted_avg']['format']); + } + + + public function testValueRequiresFieldOrScript(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + public function testKey(): void { - $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg('grade', 'weight'); + $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg( + value: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'grade'), + weight: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'weight'), + ); - \Tester\Assert::same('weighted_avg_grade', $weightedAvg->key()); + \Tester\Assert::same('weighted_avg', $weightedAvg->key()); } public function testCreate(): void { - $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg('grade', 'weight'); + $this->indexDocument(['grade' => 80, 'weight' => 2]); + $this->indexDocument(['grade' => 90, 'weight' => 3]); + + $weightedAvg = new \Spameri\ElasticQuery\Aggregation\WeightedAvg( + value: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'grade'), + weight: new \Spameri\ElasticQuery\Aggregation\WeightedAvg\WeightedAvgValue(field: 'weight'), + ); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); $elasticQuery->aggregation()->add( @@ -55,43 +95,9 @@ class WeightedAvg extends \Tester\TestCase ), ); - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $result = $this->search($elasticQuery); - \curl_exec($ch); + \Tester\Assert::same(2, $result->stats()->total()); } } From 1ccbeb830e91289f0dae6e2911af8f47d94d44ff Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:34:42 +0200 Subject: [PATCH 89/97] feat(agg): bucket aggregation expansion + Composite typed sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Term: min_doc_count, shard_size, shard_min_doc_count, show_term_doc_count_error, script, collect_mode, execution_hint, value_type, format; include/exclude accept array|string - MultiTerms: order, min_doc_count, shard_size, shard_min_doc_count, collect_mode, format; terms accept array - RareTerms: include, exclude, missing - SignificantTerms: shard_size, shard_min_doc_count, execution_hint, background_filter (LeafQueryInterface), heuristic constants - SignificantText: shard_size, shard_min_doc_count, min_doc_count, background_filter, source_fields - Range: script, missing, format - DateRange: script, missing - Histogram: min_doc_count, extended_bounds, hard_bounds (new Histogram\Bounds sub-object), offset, order, script, missing, keyed, format - DateHistogram: extended_bounds, hard_bounds, keyed, order, script, missing - IpRange: rewritten with new IpRangeValue (mask/CIDR support) - Filter: rewritten to accept any LeafQueryInterface - Missing: script - Composite: typed CompositeSourceInterface with TermsSource, HistogramSource, DateHistogramSource, GeotileGridSource — each with order/missing_bucket - AdjacencyMatrix: separator, accepts LeafQueryInterface for filters - GeoDistance (agg): keyed, script, missing - GeoHashGrid/GeoTileGrid: bounds - DiversifiedSampler: execution_hint, script ResultMapper updated to handle composite buckets (array keys JSON-encoded) and ip/date string ranges in Bucket.from/to. Three pre-existing tests that asserted invalid ES output were corrected — IpRange now uses a real ip mapping, ReverseNested correctly nests inside a Nested agg, AdjacencyMatrix uses the new LeafQueryInterface filters API. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Aggregation/AdjacencyMatrix.php | 24 ++-- src/Aggregation/Composite.php | 15 +- .../Composite/CompositeSourceInterface.php | 11 ++ .../Composite/DateHistogramSource.php | 70 ++++++++++ .../Composite/GeotileGridSource.php | 50 +++++++ src/Aggregation/Composite/HistogramSource.php | 49 +++++++ src/Aggregation/Composite/TermsSource.php | 50 +++++++ src/Aggregation/DateHistogram.php | 44 +++++- src/Aggregation/DateRange.php | 10 ++ src/Aggregation/DiversifiedSampler.php | 10 ++ src/Aggregation/Filter.php | 33 +++-- src/Aggregation/GeoDistance.php | 20 ++- src/Aggregation/GeoHashGrid.php | 8 ++ src/Aggregation/GeoTileGrid.php | 8 ++ src/Aggregation/Histogram.php | 61 +++++++- src/Aggregation/Histogram/Bounds.php | 33 +++++ src/Aggregation/IpRange.php | 20 +-- src/Aggregation/IpRange/IpRangeValue.php | 57 ++++++++ src/Aggregation/Missing.php | 15 +- src/Aggregation/MultiTerms.php | 44 ++++-- src/Aggregation/Range.php | 26 +++- src/Aggregation/RareTerms.php | 28 +++- src/Aggregation/SignificantTerms.php | 46 +++++- src/Aggregation/SignificantText.php | 37 ++++- src/Aggregation/Term.php | 65 +++++++-- src/Response/Result/Aggregation/Bucket.php | 8 +- src/Response/ResultMapper.php | 3 + .../Aggregation/AdjacencyMatrix.phpt | 88 ++++-------- .../ElasticQuery/Aggregation/Composite.phpt | 132 +++++++----------- .../ElasticQuery/Aggregation/Filter.phpt | 78 +++-------- .../ElasticQuery/Aggregation/IpRange.phpt | 107 ++++++-------- .../Aggregation/ReverseNested.phpt | 97 +++++-------- 32 files changed, 905 insertions(+), 442 deletions(-) create mode 100644 src/Aggregation/Composite/CompositeSourceInterface.php create mode 100644 src/Aggregation/Composite/DateHistogramSource.php create mode 100644 src/Aggregation/Composite/GeotileGridSource.php create mode 100644 src/Aggregation/Composite/HistogramSource.php create mode 100644 src/Aggregation/Composite/TermsSource.php create mode 100644 src/Aggregation/Histogram/Bounds.php create mode 100644 src/Aggregation/IpRange/IpRangeValue.php diff --git a/src/Aggregation/AdjacencyMatrix.php b/src/Aggregation/AdjacencyMatrix.php index c268089..8a7e89a 100644 --- a/src/Aggregation/AdjacencyMatrix.php +++ b/src/Aggregation/AdjacencyMatrix.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-adjacency-matrix-aggregation.html */ @@ -11,13 +12,14 @@ class AdjacencyMatrix implements \Spameri\ElasticQuery\Aggregation\LeafAggregati { /** - * @var array + * @var array */ private array $filters; public function __construct( private string $key = 'adjacency_matrix', + private string|null $separator = null, ) { $this->filters = []; @@ -26,7 +28,7 @@ public function __construct( public function addFilter( string $name, - \Spameri\ElasticQuery\Filter\FilterCollection $filter, + \Spameri\ElasticQuery\Query\LeafQueryInterface $filter, ): void { $this->filters[$name] = $filter; @@ -46,18 +48,16 @@ public function toArray(): array { $filters = []; foreach ($this->filters as $name => $filter) { - $filterArray = $filter->toArray(); - if ($filterArray === []) { - $filterArray = ['must' => []]; - } - $filters[$name] = ['bool' => $filterArray]; + $filters[$name] = $filter->toArray(); + } + + $body = ['filters' => $filters]; + + if ($this->separator !== null) { + $body['separator'] = $this->separator; } - return [ - 'adjacency_matrix' => [ - 'filters' => $filters, - ], - ]; + return ['adjacency_matrix' => $body]; } } diff --git a/src/Aggregation/Composite.php b/src/Aggregation/Composite.php index 9da4a3a..5b4b005 100644 --- a/src/Aggregation/Composite.php +++ b/src/Aggregation/Composite.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-composite-aggregation.html */ @@ -11,7 +12,7 @@ class Composite implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInte { /** - * @var array + * @var array */ private array $sources; @@ -21,7 +22,7 @@ class Composite implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInte */ public function __construct( private string $key, - \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface $source, + \Spameri\ElasticQuery\Aggregation\Composite\CompositeSourceInterface $source, private int|null $size = null, private array|null $after = null, ) @@ -31,7 +32,7 @@ public function __construct( public function addSource( - \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface $source, + \Spameri\ElasticQuery\Aggregation\Composite\CompositeSourceInterface $source, ): void { $this->sources[$source->key()] = $source; @@ -54,9 +55,7 @@ public function toArray(): array $sources[] = [$name => $source->toArray()]; } - $array = [ - 'sources' => $sources, - ]; + $array = ['sources' => $sources]; if ($this->size !== null) { $array['size'] = $this->size; @@ -66,9 +65,7 @@ public function toArray(): array $array['after'] = $this->after; } - return [ - 'composite' => $array, - ]; + return ['composite' => $array]; } } diff --git a/src/Aggregation/Composite/CompositeSourceInterface.php b/src/Aggregation/Composite/CompositeSourceInterface.php new file mode 100644 index 0000000..a120593 --- /dev/null +++ b/src/Aggregation/Composite/CompositeSourceInterface.php @@ -0,0 +1,11 @@ +name; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = ['field' => $this->field]; + + if ($this->calendarInterval !== null) { + $body['calendar_interval'] = $this->calendarInterval; + } + + if ($this->fixedInterval !== null) { + $body['fixed_interval'] = $this->fixedInterval; + } + + if ($this->format !== null) { + $body['format'] = $this->format; + } + + if ($this->timeZone !== null) { + $body['time_zone'] = $this->timeZone; + } + + if ($this->order !== null) { + $body['order'] = $this->order; + } + + if ($this->missingBucket !== null) { + $body['missing_bucket'] = $this->missingBucket; + } + + return ['date_histogram' => $body]; + } + +} diff --git a/src/Aggregation/Composite/GeotileGridSource.php b/src/Aggregation/Composite/GeotileGridSource.php new file mode 100644 index 0000000..dbc777c --- /dev/null +++ b/src/Aggregation/Composite/GeotileGridSource.php @@ -0,0 +1,50 @@ +name; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = ['field' => $this->field]; + + if ($this->precision !== null) { + $body['precision'] = $this->precision; + } + + if ($this->order !== null) { + $body['order'] = $this->order; + } + + if ($this->missingBucket !== null) { + $body['missing_bucket'] = $this->missingBucket; + } + + return ['geotile_grid' => $body]; + } + +} diff --git a/src/Aggregation/Composite/HistogramSource.php b/src/Aggregation/Composite/HistogramSource.php new file mode 100644 index 0000000..4ba67d8 --- /dev/null +++ b/src/Aggregation/Composite/HistogramSource.php @@ -0,0 +1,49 @@ +name; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = [ + 'field' => $this->field, + 'interval' => $this->interval, + ]; + + if ($this->order !== null) { + $body['order'] = $this->order; + } + + if ($this->missingBucket !== null) { + $body['missing_bucket'] = $this->missingBucket; + } + + return ['histogram' => $body]; + } + +} diff --git a/src/Aggregation/Composite/TermsSource.php b/src/Aggregation/Composite/TermsSource.php new file mode 100644 index 0000000..f3b7f3c --- /dev/null +++ b/src/Aggregation/Composite/TermsSource.php @@ -0,0 +1,50 @@ +name; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $body = ['field' => $this->field]; + + if ($this->order !== null) { + $body['order'] = $this->order; + } + + if ($this->missingBucket !== null) { + $body['missing_bucket'] = $this->missingBucket; + } + + if ($this->missingOrder !== null) { + $body['missing_order'] = $this->missingOrder; + } + + return ['terms' => $body]; + } + +} diff --git a/src/Aggregation/DateHistogram.php b/src/Aggregation/DateHistogram.php index e14507b..0ead3e7 100644 --- a/src/Aggregation/DateHistogram.php +++ b/src/Aggregation/DateHistogram.php @@ -4,12 +4,18 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html */ class DateHistogram implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + /** + * @param array|null $extendedBounds + * @param array|null $hardBounds + * @param array|null $order + */ public function __construct( private string $field, private string|null $calendarInterval = null, @@ -18,6 +24,12 @@ public function __construct( private string|null $timeZone = null, private int|null $minDocCount = null, private string|null $offset = null, + private array|null $extendedBounds = null, + private array|null $hardBounds = null, + private bool|null $keyed = null, + private array|null $order = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $missing = null, ) { if ($this->calendarInterval === null && $this->fixedInterval === null) { @@ -45,9 +57,7 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->calendarInterval !== null) { $array['calendar_interval'] = $this->calendarInterval; @@ -73,9 +83,31 @@ public function toArray(): array $array['offset'] = $this->offset; } - return [ - 'date_histogram' => $array, - ]; + if ($this->extendedBounds !== null) { + $array['extended_bounds'] = $this->extendedBounds; + } + + if ($this->hardBounds !== null) { + $array['hard_bounds'] = $this->hardBounds; + } + + if ($this->keyed !== null) { + $array['keyed'] = $this->keyed; + } + + if ($this->order !== null) { + $array['order'] = $this->order; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + return ['date_histogram' => $array]; } } diff --git a/src/Aggregation/DateRange.php b/src/Aggregation/DateRange.php index b866ee1..c33544b 100644 --- a/src/Aggregation/DateRange.php +++ b/src/Aggregation/DateRange.php @@ -16,6 +16,8 @@ public function __construct( private string|null $format = null, private string|null $timeZone = null, private bool $keyed = false, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $missing = null, ) { } @@ -58,6 +60,14 @@ public function toArray(): array $array['ranges'][] = $range->toArray(); } + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + return [ 'date_range' => $array, ]; diff --git a/src/Aggregation/DiversifiedSampler.php b/src/Aggregation/DiversifiedSampler.php index 08e7b8a..be1cd9e 100644 --- a/src/Aggregation/DiversifiedSampler.php +++ b/src/Aggregation/DiversifiedSampler.php @@ -15,6 +15,8 @@ public function __construct( private int $shardSize = 100, private int|null $maxDocsPerValue = null, private string $key = 'diversified_sampler', + private string|null $executionHint = null, + private \Spameri\ElasticQuery\Script|null $script = null, ) { } @@ -40,6 +42,14 @@ public function toArray(): array $array['max_docs_per_value'] = $this->maxDocsPerValue; } + if ($this->executionHint !== null) { + $array['execution_hint'] = $this->executionHint; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + return [ 'diversified_sampler' => $array, ]; diff --git a/src/Aggregation/Filter.php b/src/Aggregation/Filter.php index cc11cca..0025811 100644 --- a/src/Aggregation/Filter.php +++ b/src/Aggregation/Filter.php @@ -4,26 +4,37 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-filter-aggregation.html */ -class Filter extends \Spameri\ElasticQuery\Filter\FilterCollection - implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface +class Filter implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { - public function toArray(): array + public function __construct( + private \Spameri\ElasticQuery\Query\LeafQueryInterface|null $filter = null, + private string $key = 'filter', + ) + { + } + + + public function key(): string { - $array = parent::toArray(); + return $this->key; + } + - if ($array === []) { - $array['must'] = []; + /** + * @return array> + */ + public function toArray(): array + { + if ($this->filter === null) { + return ['filter' => ['bool' => new \stdClass()]]; } - return [ - 'filter' => [ - 'bool' => $array, - ], - ]; + return ['filter' => $this->filter->toArray()]; } } diff --git a/src/Aggregation/GeoDistance.php b/src/Aggregation/GeoDistance.php index da58905..5014d49 100644 --- a/src/Aggregation/GeoDistance.php +++ b/src/Aggregation/GeoDistance.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geodistance-aggregation.html */ @@ -17,6 +18,9 @@ public function __construct( private \Spameri\ElasticQuery\Aggregation\RangeValueCollection $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection(), private string|null $unit = null, private string|null $distanceType = null, + private bool|null $keyed = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $missing = null, ) { } @@ -55,13 +59,23 @@ public function toArray(): array $array['distance_type'] = $this->distanceType; } + if ($this->keyed !== null) { + $array['keyed'] = $this->keyed; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + foreach ($this->ranges as $range) { $array['ranges'][] = $range->toArray(); } - return [ - 'geo_distance' => $array, - ]; + return ['geo_distance' => $array]; } } diff --git a/src/Aggregation/GeoHashGrid.php b/src/Aggregation/GeoHashGrid.php index 80be25a..f338bee 100644 --- a/src/Aggregation/GeoHashGrid.php +++ b/src/Aggregation/GeoHashGrid.php @@ -10,11 +10,15 @@ class GeoHashGrid implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + /** + * @param array>|null $bounds e.g. ['top_left' => ['lat' => ..., 'lon' => ...], 'bottom_right' => [...]] + */ public function __construct( private string $field, private int|null $precision = null, private int|null $size = null, private int|null $shardSize = null, + private array|null $bounds = null, ) { } @@ -47,6 +51,10 @@ public function toArray(): array $array['shard_size'] = $this->shardSize; } + if ($this->bounds !== null) { + $array['bounds'] = $this->bounds; + } + return [ 'geohash_grid' => $array, ]; diff --git a/src/Aggregation/GeoTileGrid.php b/src/Aggregation/GeoTileGrid.php index f14a64a..d275154 100644 --- a/src/Aggregation/GeoTileGrid.php +++ b/src/Aggregation/GeoTileGrid.php @@ -10,11 +10,15 @@ class GeoTileGrid implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + /** + * @param array>|null $bounds + */ public function __construct( private string $field, private int|null $precision = null, private int|null $size = null, private int|null $shardSize = null, + private array|null $bounds = null, ) { } @@ -47,6 +51,10 @@ public function toArray(): array $array['shard_size'] = $this->shardSize; } + if ($this->bounds !== null) { + $array['bounds'] = $this->bounds; + } + return [ 'geotile_grid' => $array, ]; diff --git a/src/Aggregation/Histogram.php b/src/Aggregation/Histogram.php index c211a86..b161d93 100644 --- a/src/Aggregation/Histogram.php +++ b/src/Aggregation/Histogram.php @@ -11,9 +11,21 @@ class Histogram implements LeafAggregationInterface { + /** + * @param array|null $order + */ public function __construct( private string $field, private int $interval, + private int|null $minDocCount = null, + private \Spameri\ElasticQuery\Aggregation\Histogram\Bounds|null $extendedBounds = null, + private \Spameri\ElasticQuery\Aggregation\Histogram\Bounds|null $hardBounds = null, + private float|int|null $offset = null, + private array|null $order = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private float|int|string|null $missing = null, + private bool|null $keyed = null, + private string|null $format = null, ) { } @@ -25,14 +37,53 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { - return [ - 'histogram' => [ - 'field' => $this->field, - 'interval' => $this->interval, - ], + $array = [ + 'field' => $this->field, + 'interval' => $this->interval, ]; + + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + if ($this->extendedBounds !== null) { + $array['extended_bounds'] = $this->extendedBounds->toArray(); + } + + if ($this->hardBounds !== null) { + $array['hard_bounds'] = $this->hardBounds->toArray(); + } + + if ($this->offset !== null) { + $array['offset'] = $this->offset; + } + + if ($this->order !== null) { + $array['order'] = $this->order; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->keyed !== null) { + $array['keyed'] = $this->keyed; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['histogram' => $array]; } } diff --git a/src/Aggregation/Histogram/Bounds.php b/src/Aggregation/Histogram/Bounds.php new file mode 100644 index 0000000..e68b203 --- /dev/null +++ b/src/Aggregation/Histogram/Bounds.php @@ -0,0 +1,33 @@ + + */ + public function toArray(): array + { + return [ + 'min' => $this->min, + 'max' => $this->max, + ]; + } + +} diff --git a/src/Aggregation/IpRange.php b/src/Aggregation/IpRange.php index 9667a18..007e59c 100644 --- a/src/Aggregation/IpRange.php +++ b/src/Aggregation/IpRange.php @@ -4,15 +4,19 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-iprange-aggregation.html */ class IpRange implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + /** + * @param array $ranges + */ public function __construct( private string $field, - private \Spameri\ElasticQuery\Aggregation\RangeValueCollection $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection(), + private array $ranges = [], private bool $keyed = false, ) { @@ -25,20 +29,12 @@ public function key(): string } - public function ranges(): \Spameri\ElasticQuery\Aggregation\RangeValueCollection - { - return $this->ranges; - } - - /** * @return array> */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->keyed === true) { $array['keyed'] = true; @@ -48,9 +44,7 @@ public function toArray(): array $array['ranges'][] = $range->toArray(); } - return [ - 'ip_range' => $array, - ]; + return ['ip_range' => $array]; } } diff --git a/src/Aggregation/IpRange/IpRangeValue.php b/src/Aggregation/IpRange/IpRangeValue.php new file mode 100644 index 0000000..3a36c37 --- /dev/null +++ b/src/Aggregation/IpRange/IpRangeValue.php @@ -0,0 +1,57 @@ +key; + } + + + /** + * @return array + */ + public function toArray(): array + { + $array = ['key' => $this->key]; + + if ($this->mask !== null) { + $array['mask'] = $this->mask; + + } else { + if ($this->from !== null) { + $array['from'] = $this->from; + } + if ($this->to !== null) { + $array['to'] = $this->to; + } + } + + return $array; + } + +} diff --git a/src/Aggregation/Missing.php b/src/Aggregation/Missing.php index 16848d2..94c79e3 100644 --- a/src/Aggregation/Missing.php +++ b/src/Aggregation/Missing.php @@ -12,6 +12,7 @@ class Missing implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterf public function __construct( private string $field, + private \Spameri\ElasticQuery\Script|null $script = null, ) { } @@ -24,15 +25,17 @@ public function key(): string /** - * @return array> + * @return array> */ public function toArray(): array { - return [ - 'missing' => [ - 'field' => $this->field, - ], - ]; + $array = ['field' => $this->field]; + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + return ['missing' => $array]; } } diff --git a/src/Aggregation/MultiTerms.php b/src/Aggregation/MultiTerms.php index a59b881..df6eef8 100644 --- a/src/Aggregation/MultiTerms.php +++ b/src/Aggregation/MultiTerms.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-multi-terms-aggregation.html */ @@ -11,12 +12,19 @@ class MultiTerms implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInt { /** - * @param array $terms + * @param array> $terms Either field names or {field, missing} objects. + * @param array>|null $order */ public function __construct( private array $terms, private int|null $size = null, private string $key = 'multi_terms', + private array|null $order = null, + private int|null $minDocCount = null, + private int|null $shardSize = null, + private int|null $shardMinDocCount = null, + private string|null $collectMode = null, + private string|null $format = null, ) { } @@ -35,20 +43,40 @@ public function toArray(): array { $termsArray = []; foreach ($this->terms as $term) { - $termsArray[] = ['field' => $term]; + $termsArray[] = \is_array($term) ? $term : ['field' => $term]; } - $array = [ - 'terms' => $termsArray, - ]; + $array = ['terms' => $termsArray]; if ($this->size !== null) { $array['size'] = $this->size; } - return [ - 'multi_terms' => $array, - ]; + if ($this->order !== null) { + $array['order'] = $this->order; + } + + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + if ($this->shardMinDocCount !== null) { + $array['shard_min_doc_count'] = $this->shardMinDocCount; + } + + if ($this->collectMode !== null) { + $array['collect_mode'] = $this->collectMode; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['multi_terms' => $array]; } } diff --git a/src/Aggregation/Range.php b/src/Aggregation/Range.php index 835bd8c..8107d73 100644 --- a/src/Aggregation/Range.php +++ b/src/Aggregation/Range.php @@ -15,6 +15,9 @@ public function __construct( private string $field, private bool $keyed = false, private \Spameri\ElasticQuery\Aggregation\RangeValueCollection $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection(), + private \Spameri\ElasticQuery\Script|null $script = null, + private float|int|string|null $missing = null, + private string|null $format = null, ) { } @@ -26,11 +29,12 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->keyed === true) { $array['keyed'] = true; @@ -40,9 +44,19 @@ public function toArray(): array $array['ranges'][] = $range->toArray(); } - return [ - 'range' => $array, - ]; + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['range' => $array]; } diff --git a/src/Aggregation/RareTerms.php b/src/Aggregation/RareTerms.php index 804fa0b..636bcd2 100644 --- a/src/Aggregation/RareTerms.php +++ b/src/Aggregation/RareTerms.php @@ -4,16 +4,24 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-rare-terms-aggregation.html */ class RareTerms implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + /** + * @param string|array|null $include + * @param string|array|null $exclude + */ public function __construct( private string $field, private int|null $maxDocCount = null, private float|null $precision = null, + private string|array|null $include = null, + private string|array|null $exclude = null, + private string|null $missing = null, ) { } @@ -30,9 +38,7 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->maxDocCount !== null) { $array['max_doc_count'] = $this->maxDocCount; @@ -42,9 +48,19 @@ public function toArray(): array $array['precision'] = $this->precision; } - return [ - 'rare_terms' => $array, - ]; + if ($this->include !== null) { + $array['include'] = $this->include; + } + + if ($this->exclude !== null) { + $array['exclude'] = $this->exclude; + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + return ['rare_terms' => $array]; } } diff --git a/src/Aggregation/SignificantTerms.php b/src/Aggregation/SignificantTerms.php index e175a28..0735c7d 100644 --- a/src/Aggregation/SignificantTerms.php +++ b/src/Aggregation/SignificantTerms.php @@ -4,16 +4,32 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-significantterms-aggregation.html */ class SignificantTerms implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + public const HEURISTIC_JLH = 'jlh'; + public const HEURISTIC_MUTUAL_INFORMATION = 'mutual_information'; + public const HEURISTIC_CHI_SQUARE = 'chi_square'; + public const HEURISTIC_GND = 'gnd'; + public const HEURISTIC_PERCENTAGE = 'percentage'; + public const HEURISTIC_SCRIPT = 'script_heuristic'; + + /** + * @param array|null $heuristic e.g. ['mutual_information' => ['include_negatives' => true]] + */ public function __construct( private string $field, private int|null $size = null, private int|null $minDocCount = null, + private int|null $shardSize = null, + private int|null $shardMinDocCount = null, + private string|null $executionHint = null, + private \Spameri\ElasticQuery\Query\LeafQueryInterface|null $backgroundFilter = null, + private array|null $heuristic = null, ) { } @@ -30,9 +46,7 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->size !== null) { $array['size'] = $this->size; @@ -42,9 +56,29 @@ public function toArray(): array $array['min_doc_count'] = $this->minDocCount; } - return [ - 'significant_terms' => $array, - ]; + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + if ($this->shardMinDocCount !== null) { + $array['shard_min_doc_count'] = $this->shardMinDocCount; + } + + if ($this->executionHint !== null) { + $array['execution_hint'] = $this->executionHint; + } + + if ($this->backgroundFilter !== null) { + $array['background_filter'] = $this->backgroundFilter->toArray(); + } + + if ($this->heuristic !== null) { + foreach ($this->heuristic as $name => $config) { + $array[$name] = $config; + } + } + + return ['significant_terms' => $array]; } } diff --git a/src/Aggregation/SignificantText.php b/src/Aggregation/SignificantText.php index ef2ce48..20bd38d 100644 --- a/src/Aggregation/SignificantText.php +++ b/src/Aggregation/SignificantText.php @@ -4,16 +4,25 @@ namespace Spameri\ElasticQuery\Aggregation; + /** * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-significanttext-aggregation.html */ class SignificantText implements \Spameri\ElasticQuery\Aggregation\LeafAggregationInterface { + /** + * @param array|null $sourceFields + */ public function __construct( private string $field, private int|null $size = null, private bool $filterDuplicateText = false, + private int|null $shardSize = null, + private int|null $shardMinDocCount = null, + private int|null $minDocCount = null, + private \Spameri\ElasticQuery\Query\LeafQueryInterface|null $backgroundFilter = null, + private array|null $sourceFields = null, ) { } @@ -30,9 +39,7 @@ public function key(): string */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->size !== null) { $array['size'] = $this->size; @@ -42,9 +49,27 @@ public function toArray(): array $array['filter_duplicate_text'] = true; } - return [ - 'significant_text' => $array, - ]; + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + if ($this->shardMinDocCount !== null) { + $array['shard_min_doc_count'] = $this->shardMinDocCount; + } + + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + if ($this->backgroundFilter !== null) { + $array['background_filter'] = $this->backgroundFilter->toArray(); + } + + if ($this->sourceFields !== null) { + $array['source_fields'] = $this->sourceFields; + } + + return ['significant_text' => $array]; } } diff --git a/src/Aggregation/Term.php b/src/Aggregation/Term.php index e3d4099..0ab9876 100644 --- a/src/Aggregation/Term.php +++ b/src/Aggregation/Term.php @@ -13,14 +13,28 @@ class Term implements LeafAggregationInterface private \Spameri\ElasticQuery\Aggregation\Terms\OrderCollection $order; + + /** + * @param string|array|null $include + * @param string|array|null $exclude + */ public function __construct( private string $field, private int $size = 0, private int|null $missing = null, \Spameri\ElasticQuery\Aggregation\Terms\OrderCollection|null $order = null, - private string|null $include = null, - private string|null $exclude = null, + private string|array|null $include = null, + private string|array|null $exclude = null, private string|null $key = null, + private int|null $minDocCount = null, + private int|null $shardSize = null, + private int|null $shardMinDocCount = null, + private bool|null $showTermDocCountError = null, + private \Spameri\ElasticQuery\Script|null $script = null, + private string|null $collectMode = null, + private string|null $executionHint = null, + private string|null $valueType = null, + private string|null $format = null, ) { $this->order = $order ?? new \Spameri\ElasticQuery\Aggregation\Terms\OrderCollection(); @@ -33,11 +47,12 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { - $array = [ - 'field' => $this->field, - ]; + $array = ['field' => $this->field]; if ($this->size > 0) { $array['size'] = $this->size; @@ -59,9 +74,43 @@ public function toArray(): array $array['exclude'] = $this->exclude; } - return [ - 'terms' => $array, - ]; + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + if ($this->shardMinDocCount !== null) { + $array['shard_min_doc_count'] = $this->shardMinDocCount; + } + + if ($this->showTermDocCountError !== null) { + $array['show_term_doc_count_error'] = $this->showTermDocCountError; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + if ($this->collectMode !== null) { + $array['collect_mode'] = $this->collectMode; + } + + if ($this->executionHint !== null) { + $array['execution_hint'] = $this->executionHint; + } + + if ($this->valueType !== null) { + $array['value_type'] = $this->valueType; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['terms' => $array]; } } diff --git a/src/Response/Result/Aggregation/Bucket.php b/src/Response/Result/Aggregation/Bucket.php index 48f7bfb..df1775b 100644 --- a/src/Response/Result/Aggregation/Bucket.php +++ b/src/Response/Result/Aggregation/Bucket.php @@ -12,8 +12,8 @@ public function __construct( private string $key, private int $docCount, private int|null $position = null, - private int|float|null $from = null, - private int|float|null $to = null, + private int|float|string|null $from = null, + private int|float|string|null $to = null, ) { } @@ -37,13 +37,13 @@ public function position(): int|null } - public function from(): float|int|null + public function from(): float|int|string|null { return $this->from; } - public function to(): float|int|null + public function to(): float|int|string|null { return $this->to; } diff --git a/src/Response/ResultMapper.php b/src/Response/ResultMapper.php index 79b96be..7e31691 100644 --- a/src/Response/ResultMapper.php +++ b/src/Response/ResultMapper.php @@ -262,6 +262,9 @@ private function mapBucket( ): \Spameri\ElasticQuery\Response\Result\Aggregation\Bucket { $bucketKey = $bucketArray['key'] ?? $bucketPosition; + if (\is_array($bucketKey)) { + $bucketKey = (string) \json_encode($bucketKey); + } return new \Spameri\ElasticQuery\Response\Result\Aggregation\Bucket( (string) $bucketKey, diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt index c56036c..2fbe5f1 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/AdjacencyMatrix.phpt @@ -5,36 +5,37 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class AdjacencyMatrix extends \Tester\TestCase +class AdjacencyMatrix extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_adjacency_matrix'; + protected const INDEX = 'spameri_test_aggregation_adjacency_matrix'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['status' => ['type' => 'keyword']]]]; } public function testToArray(): void { - $filter = new \Spameri\ElasticQuery\Filter\FilterCollection(); - $filter->must()->add(new \Spameri\ElasticQuery\Query\Term('status', 'active')); - $matrix = new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix(); - $matrix->addFilter('active', $filter); + $matrix->addFilter('active', new \Spameri\ElasticQuery\Query\Term('status', 'active')); $array = $matrix->toArray(); - \Tester\Assert::true(isset($array['adjacency_matrix']['filters']['active'])); - \Tester\Assert::true(isset($array['adjacency_matrix']['filters']['active']['bool'])); + \Tester\Assert::same( + 'active', + $array['adjacency_matrix']['filters']['active']['term']['status']['value'], + ); + } + + + public function testToArrayWithSeparator(): void + { + $matrix = new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix(separator: '|'); + + \Tester\Assert::same('|', $matrix->toArray()['adjacency_matrix']['separator']); } @@ -49,58 +50,19 @@ class AdjacencyMatrix extends \Tester\TestCase public function testCreate(): void { - $filterA = new \Spameri\ElasticQuery\Filter\FilterCollection(); - $filterA->must()->add(new \Spameri\ElasticQuery\Query\Term('status', 'active')); + $this->indexDocument(['status' => 'active']); + $this->indexDocument(['status' => 'inactive']); $matrix = new \Spameri\ElasticQuery\Aggregation\AdjacencyMatrix(); - $matrix->addFilter('group_a', $filterA); + $matrix->addFilter('group_active', new \Spameri\ElasticQuery\Query\Term('status', 'active')); + $matrix->addFilter('group_inactive', new \Spameri\ElasticQuery\Query\Term('status', 'inactive')); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'matrix', - null, - $matrix, - ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'matrix', null, $matrix, + )); - \curl_exec($ch); + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt index 1c6c049..538c0b7 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Composite.phpt @@ -5,120 +5,90 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class Composite extends \Tester\TestCase +class Composite extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_composite'; + protected const INDEX = 'spameri_test_aggregation_composite'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'product' => ['type' => 'keyword'], + 'price' => ['type' => 'long'], + ], + ], + ]; } public function testToArray(): void { $composite = new \Spameri\ElasticQuery\Aggregation\Composite( - key: 'my_buckets', - source: new \Spameri\ElasticQuery\Aggregation\Term('product'), - size: 100, + key: 'my_composite', + source: new \Spameri\ElasticQuery\Aggregation\Composite\TermsSource( + name: 'product', + field: 'product', + ), ); - $composite->addSource(new \Spameri\ElasticQuery\Aggregation\Histogram('price', 50)); $array = $composite->toArray(); - \Tester\Assert::same(100, $array['composite']['size']); - \Tester\Assert::count(2, $array['composite']['sources']); - \Tester\Assert::same('product', $array['composite']['sources'][0]['product']['terms']['field']); - \Tester\Assert::same('price', $array['composite']['sources'][1]['price']['histogram']['field']); + \Tester\Assert::same( + 'product', + $array['composite']['sources'][0]['product']['terms']['field'], + ); } - public function testToArrayWithAfter(): void + public function testToArrayWithMultipleSourcesAndAfter(): void { $composite = new \Spameri\ElasticQuery\Aggregation\Composite( - key: 'my_buckets', - source: new \Spameri\ElasticQuery\Aggregation\Term('product'), - after: ['product' => 'foo'], + key: 'mc', + source: new \Spameri\ElasticQuery\Aggregation\Composite\TermsSource( + name: 'product', + field: 'product', + order: 'asc', + missingBucket: true, + ), + size: 10, + after: ['product' => 'a'], ); + $composite->addSource(new \Spameri\ElasticQuery\Aggregation\Composite\HistogramSource( + name: 'price', + field: 'price', + interval: 10, + )); $array = $composite->toArray(); - \Tester\Assert::same(['product' => 'foo'], $array['composite']['after']); - } - - - public function testKey(): void - { - $composite = new \Spameri\ElasticQuery\Aggregation\Composite( - key: 'my_buckets', - source: new \Spameri\ElasticQuery\Aggregation\Term('product'), - ); - - \Tester\Assert::same('my_buckets', $composite->key()); + \Tester\Assert::count(2, $array['composite']['sources']); + \Tester\Assert::same(10, $array['composite']['size']); + \Tester\Assert::same(['product' => 'a'], $array['composite']['after']); } public function testCreate(): void { - $composite = new \Spameri\ElasticQuery\Aggregation\Composite( - key: 'my_buckets', - source: new \Spameri\ElasticQuery\Aggregation\Term('product'), - ); + $this->indexDocument(['product' => 'a', 'price' => 10]); + $this->indexDocument(['product' => 'b', 'price' => 20]); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'composite_agg', - null, - $composite, + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'composite_agg', + null, + new \Spameri\ElasticQuery\Aggregation\Composite( + key: 'composite_agg', + source: new \Spameri\ElasticQuery\Aggregation\Composite\TermsSource( + name: 'product', + field: 'product', + ), ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); - - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + )); - \curl_exec($ch); + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Filter.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Filter.phpt index 0cea8ee..a18984f 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/Filter.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Filter.phpt @@ -5,21 +5,15 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class Filter extends \Tester\TestCase +class Filter extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_filter'; + protected const INDEX = 'spameri_test_aggregation_filter'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['status' => ['type' => 'keyword']]]]; } @@ -29,81 +23,45 @@ class Filter extends \Tester\TestCase $array = $filter->toArray(); - \Tester\Assert::true(isset($array['filter']['bool']['must'])); - \Tester\Assert::same([], $array['filter']['bool']['must']); + \Tester\Assert::true(isset($array['filter'])); } public function testToArrayWithQuery(): void { - $filter = new \Spameri\ElasticQuery\Aggregation\Filter(); - $filter->must()->add(new \Spameri\ElasticQuery\Query\Term('status', 'active')); + $filter = new \Spameri\ElasticQuery\Aggregation\Filter( + filter: new \Spameri\ElasticQuery\Query\Term('status', 'active'), + ); $array = $filter->toArray(); - \Tester\Assert::true(isset($array['filter']['bool']['bool']['must'])); - \Tester\Assert::count(1, $array['filter']['bool']['bool']['must']); + \Tester\Assert::same('active', $array['filter']['term']['status']['value']); } public function testKey(): void { - $filter = new \Spameri\ElasticQuery\Aggregation\Filter(); - - \Tester\Assert::same('', $filter->key()); + \Tester\Assert::same('filter', (new \Spameri\ElasticQuery\Aggregation\Filter())->key()); } - public function testCreateEmpty(): void + public function testCreate(): void { - $filter = new \Spameri\ElasticQuery\Aggregation\Filter(); + $this->indexDocument(['status' => 'active']); + $this->indexDocument(['status' => 'inactive']); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); $elasticQuery->aggregation()->add( new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'all_products', + 'active_only', null, - $filter, - ), - ); - - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), + new \Spameri\ElasticQuery\Aggregation\Filter( + filter: new \Spameri\ElasticQuery\Query\Term('status', 'active'), + ), ), ); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt index 919c7b9..f4b6e70 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/IpRange.phpt @@ -5,33 +5,26 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class IpRange extends \Tester\TestCase +class IpRange extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_ip_range'; + protected const INDEX = 'spameri_test_aggregation_ip_range'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return ['mappings' => ['properties' => ['ip' => ['type' => 'ip']]]]; } - public function testToArray(): void + public function testToArrayFromTo(): void { - $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( - new \Spameri\ElasticQuery\Aggregation\RangeValue('low', null, '10.0.0.5'), - new \Spameri\ElasticQuery\Aggregation\RangeValue('high', '10.0.0.5', null), - ); $ipRange = new \Spameri\ElasticQuery\Aggregation\IpRange( field: 'ip', - ranges: $ranges, + ranges: [ + new \Spameri\ElasticQuery\Aggregation\IpRange\IpRangeValue('low', null, '10.0.0.5'), + new \Spameri\ElasticQuery\Aggregation\IpRange\IpRangeValue('high', '10.0.0.5', null), + ], ); $array = $ipRange->toArray(); @@ -41,70 +34,50 @@ class IpRange extends \Tester\TestCase } - public function testKey(): void - { - $ipRange = new \Spameri\ElasticQuery\Aggregation\IpRange('ip'); - - \Tester\Assert::same('ip_range_ip', $ipRange->key()); - } - - - public function testCreate(): void + public function testToArrayCidr(): void { - $ranges = new \Spameri\ElasticQuery\Aggregation\RangeValueCollection( - new \Spameri\ElasticQuery\Aggregation\RangeValue('private', null, '10.0.0.0'), - ); $ipRange = new \Spameri\ElasticQuery\Aggregation\IpRange( field: 'ip', - ranges: $ranges, + ranges: [ + new \Spameri\ElasticQuery\Aggregation\IpRange\IpRangeValue('private', mask: '10.0.0.0/8'), + ], ); - $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'ip_ranges', - null, - $ipRange, - ), - ); + $array = $ipRange->toArray(); - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), - ); + \Tester\Assert::same('10.0.0.0/8', $array['ip_range']['ranges'][0]['mask']); + } - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), - ); - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); + public function testRequiresFromToOrMask(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\IpRange\IpRangeValue('k'); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); } - public function tearDown(): void + public function testCreate(): void { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $this->indexDocument(['ip' => '10.0.0.1']); + $this->indexDocument(['ip' => '192.168.0.1']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'ip_buckets', + null, + new \Spameri\ElasticQuery\Aggregation\IpRange( + field: 'ip', + ranges: [ + new \Spameri\ElasticQuery\Aggregation\IpRange\IpRangeValue('private', mask: '10.0.0.0/8'), + ], + ), + )); - \curl_exec($ch); + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt index 4251b23..2f96ade 100644 --- a/tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt +++ b/tests/SpameriTests/ElasticQuery/Aggregation/ReverseNested.phpt @@ -5,21 +5,27 @@ namespace SpameriTests\ElasticQuery\Aggregation; require_once __DIR__ . '/../../bootstrap.php'; -class ReverseNested extends \Tester\TestCase +class ReverseNested extends \SpameriTests\ElasticQuery\AbstractElasticTestCase { - private const INDEX = 'spameri_test_aggregation_reverse_nested'; + protected const INDEX = 'spameri_test_aggregation_reverse_nested'; - public function setUp(): void + protected function mapping(): array|null { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'PUT'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - - \curl_exec($ch); + return [ + 'mappings' => [ + 'properties' => [ + 'name' => ['type' => 'keyword'], + 'comments' => [ + 'type' => 'nested', + 'properties' => [ + 'author' => ['type' => 'keyword'], + ], + ], + ], + ], + ]; } @@ -29,7 +35,6 @@ class ReverseNested extends \Tester\TestCase $array = $reverseNested->toArray(); - \Tester\Assert::true(isset($array['reverse_nested'])); \Tester\Assert::type(\stdClass::class, $array['reverse_nested']); } @@ -38,75 +43,43 @@ class ReverseNested extends \Tester\TestCase { $reverseNested = new \Spameri\ElasticQuery\Aggregation\ReverseNested('parent'); - $array = $reverseNested->toArray(); - - \Tester\Assert::same('parent', $array['reverse_nested']['path']); + \Tester\Assert::same('parent', $reverseNested->toArray()['reverse_nested']['path']); } public function testKey(): void { - \Tester\Assert::same( - 'reverse_nested_root', - (new \Spameri\ElasticQuery\Aggregation\ReverseNested())->key(), - ); - \Tester\Assert::same( - 'reverse_nested_parent', - (new \Spameri\ElasticQuery\Aggregation\ReverseNested('parent'))->key(), - ); + \Tester\Assert::same('reverse_nested_root', (new \Spameri\ElasticQuery\Aggregation\ReverseNested())->key()); + \Tester\Assert::same('reverse_nested_parent', (new \Spameri\ElasticQuery\Aggregation\ReverseNested('parent'))->key()); } public function testCreate(): void { - $reverseNested = new \Spameri\ElasticQuery\Aggregation\ReverseNested(); + $this->indexDocument([ + 'name' => 'post', + 'comments' => [['author' => 'john']], + ]); $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); - $elasticQuery->aggregation()->add( - new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( - 'back', - null, - $reverseNested, - ), - ); - $document = new \Spameri\ElasticQuery\Document( - self::INDEX, - new \Spameri\ElasticQuery\Document\Body\Plain( - $elasticQuery->toArray(), - ), + // reverse_nested must live inside a nested agg + $reverseNestedAgg = new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'back_to_root', + null, + new \Spameri\ElasticQuery\Aggregation\ReverseNested(), ); - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . $document->index . '/_search'); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'GET'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); - \curl_setopt( - $ch, - \CURLOPT_POSTFIELDS, - \json_encode($document->toArray()['body']), + $nestedAgg = new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'comments_nested', + null, + new \Spameri\ElasticQuery\Aggregation\Nested('comments'), + $reverseNestedAgg, ); - \Tester\Assert::noError(static function () use ($ch): void { - $response = \curl_exec($ch); - $resultMapper = new \Spameri\ElasticQuery\Response\ResultMapper(); - /** @var \Spameri\ElasticQuery\Response\ResultSearch $result */ - $result = $resultMapper->map(\json_decode($response, true)); - \Tester\Assert::type(\Spameri\ElasticQuery\Response\ResultSearch::class, $result); - }); - } - - - public function tearDown(): void - { - $ch = \curl_init(); - \curl_setopt($ch, \CURLOPT_URL, \ELASTICSEARCH_HOST . '/' . self::INDEX); - \curl_setopt($ch, \CURLOPT_RETURNTRANSFER, 1); - \curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, 'DELETE'); - \curl_setopt($ch, \CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + $elasticQuery->aggregation()->add($nestedAgg); - \curl_exec($ch); + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); } } From ec55e29df0905d165cb5549cec732541a083547f Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:42:05 +0200 Subject: [PATCH 90/97] feat(agg): add 16 new aggregation types Bucket aggs: - Filters (plural, generic named-filters bucket) - AutoDateHistogram (auto-tunes interval) - VariableWidthHistogram - CategorizeText (ML) - FrequentItemSets (ML) - IpPrefix - TimeSeries Metric aggs: - TopMetrics - GeoLine (gold license) - TTest - Rate - MatrixStats Pipeline / sampler / ML: - RandomSampler - CumulativeCardinality - ExtendedStatsBucket - Inference ResultMapper updated to handle named buckets (string keys from Filters aggregation), preserving the bucket name as the key. License-gated aggs (GeoLine, CategorizeText) gracefully skip in testCreate when running against basic-tier ES. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Aggregation/AutoDateHistogram.php | 62 ++++++++++++++ src/Aggregation/CategorizeText.php | 80 +++++++++++++++++++ src/Aggregation/CumulativeCardinality.php | 43 ++++++++++ src/Aggregation/ExtendedStatsBucket.php | 53 ++++++++++++ src/Aggregation/Filters.php | 70 ++++++++++++++++ src/Aggregation/FrequentItemSets.php | 66 +++++++++++++++ src/Aggregation/GeoLine.php | 57 +++++++++++++ src/Aggregation/Inference.php | 53 ++++++++++++ src/Aggregation/IpPrefix.php | 61 ++++++++++++++ src/Aggregation/MatrixStats.php | 57 +++++++++++++ src/Aggregation/RandomSampler.php | 48 +++++++++++ src/Aggregation/Rate.php | 62 ++++++++++++++ src/Aggregation/TTest.php | 52 ++++++++++++ src/Aggregation/TimeSeries.php | 47 +++++++++++ src/Aggregation/TopMetrics.php | 61 ++++++++++++++ src/Aggregation/VariableWidthHistogram.php | 51 ++++++++++++ src/Response/ResultMapper.php | 8 +- .../Aggregation/AutoDateHistogram.phpt | 52 ++++++++++++ .../Aggregation/CategorizeText.phpt | 58 ++++++++++++++ .../Aggregation/CumulativeCardinality.phpt | 29 +++++++ .../Aggregation/ExtendedStatsBucket.phpt | 32 ++++++++ .../ElasticQuery/Aggregation/Filters.phpt | 67 ++++++++++++++++ .../Aggregation/FrequentItemSets.phpt | 54 +++++++++++++ .../ElasticQuery/Aggregation/GeoLine.phpt | 70 ++++++++++++++++ .../ElasticQuery/Aggregation/Inference.phpt | 30 +++++++ .../ElasticQuery/Aggregation/IpPrefix.phpt | 55 +++++++++++++ .../ElasticQuery/Aggregation/MatrixStats.phpt | 68 ++++++++++++++++ .../Aggregation/RandomSampler.phpt | 29 +++++++ .../ElasticQuery/Aggregation/Rate.phpt | 76 ++++++++++++++++++ .../ElasticQuery/Aggregation/TTest.phpt | 61 ++++++++++++++ .../ElasticQuery/Aggregation/TimeSeries.phpt | 39 +++++++++ .../ElasticQuery/Aggregation/TopMetrics.phpt | 72 +++++++++++++++++ .../Aggregation/VariableWidthHistogram.phpt | 54 +++++++++++++ 33 files changed, 1776 insertions(+), 1 deletion(-) create mode 100644 src/Aggregation/AutoDateHistogram.php create mode 100644 src/Aggregation/CategorizeText.php create mode 100644 src/Aggregation/CumulativeCardinality.php create mode 100644 src/Aggregation/ExtendedStatsBucket.php create mode 100644 src/Aggregation/Filters.php create mode 100644 src/Aggregation/FrequentItemSets.php create mode 100644 src/Aggregation/GeoLine.php create mode 100644 src/Aggregation/Inference.php create mode 100644 src/Aggregation/IpPrefix.php create mode 100644 src/Aggregation/MatrixStats.php create mode 100644 src/Aggregation/RandomSampler.php create mode 100644 src/Aggregation/Rate.php create mode 100644 src/Aggregation/TTest.php create mode 100644 src/Aggregation/TimeSeries.php create mode 100644 src/Aggregation/TopMetrics.php create mode 100644 src/Aggregation/VariableWidthHistogram.php create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/AutoDateHistogram.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/CategorizeText.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/CumulativeCardinality.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStatsBucket.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Filters.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/FrequentItemSets.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/GeoLine.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Inference.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/IpPrefix.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/MatrixStats.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/RandomSampler.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/Rate.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/TTest.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/TimeSeries.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/TopMetrics.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Aggregation/VariableWidthHistogram.phpt diff --git a/src/Aggregation/AutoDateHistogram.php b/src/Aggregation/AutoDateHistogram.php new file mode 100644 index 0000000..51d5a6e --- /dev/null +++ b/src/Aggregation/AutoDateHistogram.php @@ -0,0 +1,62 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['field' => $this->field]; + + if ($this->buckets !== null) { + $array['buckets'] = $this->buckets; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + if ($this->timeZone !== null) { + $array['time_zone'] = $this->timeZone; + } + + if ($this->minimumInterval !== null) { + $array['minimum_interval'] = $this->minimumInterval; + } + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + return ['auto_date_histogram' => $array]; + } + +} diff --git a/src/Aggregation/CategorizeText.php b/src/Aggregation/CategorizeText.php new file mode 100644 index 0000000..a2ba53e --- /dev/null +++ b/src/Aggregation/CategorizeText.php @@ -0,0 +1,80 @@ +|null $categorizationFilters + */ + public function __construct( + private string $field, + private int|null $maxUniqueTokens = null, + private int|null $maxMatchedTokens = null, + private float|null $similarityThreshold = null, + private array|null $categorizationFilters = null, + private int|null $shardSize = null, + private int|null $size = null, + private int|null $minDocCount = null, + private int|null $shardMinDocCount = null, + ) + { + } + + + public function key(): string + { + return 'categorize_text_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['field' => $this->field]; + + if ($this->maxUniqueTokens !== null) { + $array['max_unique_tokens'] = $this->maxUniqueTokens; + } + + if ($this->maxMatchedTokens !== null) { + $array['max_matched_tokens'] = $this->maxMatchedTokens; + } + + if ($this->similarityThreshold !== null) { + $array['similarity_threshold'] = $this->similarityThreshold; + } + + if ($this->categorizationFilters !== null) { + $array['categorization_filters'] = $this->categorizationFilters; + } + + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + if ($this->shardMinDocCount !== null) { + $array['shard_min_doc_count'] = $this->shardMinDocCount; + } + + return ['categorize_text' => $array]; + } + +} diff --git a/src/Aggregation/CumulativeCardinality.php b/src/Aggregation/CumulativeCardinality.php new file mode 100644 index 0000000..90a7f16 --- /dev/null +++ b/src/Aggregation/CumulativeCardinality.php @@ -0,0 +1,43 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['buckets_path' => $this->bucketsPath]; + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['cumulative_cardinality' => $array]; + } + +} diff --git a/src/Aggregation/ExtendedStatsBucket.php b/src/Aggregation/ExtendedStatsBucket.php new file mode 100644 index 0000000..651c54d --- /dev/null +++ b/src/Aggregation/ExtendedStatsBucket.php @@ -0,0 +1,53 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['buckets_path' => $this->bucketsPath]; + + if ($this->sigma !== null) { + $array['sigma'] = $this->sigma; + } + + if ($this->gapPolicy !== null) { + $array['gap_policy'] = $this->gapPolicy; + } + + if ($this->format !== null) { + $array['format'] = $this->format; + } + + return ['extended_stats_bucket' => $array]; + } + +} diff --git a/src/Aggregation/Filters.php b/src/Aggregation/Filters.php new file mode 100644 index 0000000..921aec4 --- /dev/null +++ b/src/Aggregation/Filters.php @@ -0,0 +1,70 @@ + + */ + private array $filters; + + + public function __construct( + private string $key = 'filters', + private bool|null $otherBucket = null, + private string|null $otherBucketKey = null, + ) + { + $this->filters = []; + } + + + public function addFilter( + string $name, + \Spameri\ElasticQuery\Query\LeafQueryInterface $filter, + ): void + { + $this->filters[$name] = $filter; + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $filters = []; + foreach ($this->filters as $name => $filter) { + $filters[$name] = $filter->toArray(); + } + + $body = ['filters' => $filters]; + + if ($this->otherBucket !== null) { + $body['other_bucket'] = $this->otherBucket; + } + + if ($this->otherBucketKey !== null) { + $body['other_bucket_key'] = $this->otherBucketKey; + } + + return ['filters' => $body]; + } + +} diff --git a/src/Aggregation/FrequentItemSets.php b/src/Aggregation/FrequentItemSets.php new file mode 100644 index 0000000..0d91c9f --- /dev/null +++ b/src/Aggregation/FrequentItemSets.php @@ -0,0 +1,66 @@ +> $fields Each entry: {field, ?include, ?exclude} + */ + public function __construct( + private array $fields, + private float|null $minimumSupport = null, + private int|null $minimumSetSize = null, + private int|null $size = null, + private \Spameri\ElasticQuery\Query\LeafQueryInterface|null $filter = null, + private string $key = 'frequent_item_sets', + ) + { + if ($fields === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'FrequentItemSets requires at least one field.', + ); + } + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['fields' => $this->fields]; + + if ($this->minimumSupport !== null) { + $array['minimum_support'] = $this->minimumSupport; + } + + if ($this->minimumSetSize !== null) { + $array['minimum_set_size'] = $this->minimumSetSize; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + if ($this->filter !== null) { + $array['filter'] = $this->filter->toArray(); + } + + return ['frequent_item_sets' => $array]; + } + +} diff --git a/src/Aggregation/GeoLine.php b/src/Aggregation/GeoLine.php new file mode 100644 index 0000000..764520c --- /dev/null +++ b/src/Aggregation/GeoLine.php @@ -0,0 +1,57 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'point' => ['field' => $this->pointField], + 'sort' => ['field' => $this->sortField], + ]; + + if ($this->sortOrder !== 'asc') { + $array['sort_order'] = $this->sortOrder; + } + + if ($this->includeSort !== null) { + $array['include_sort'] = $this->includeSort; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + return ['geo_line' => $array]; + } + +} diff --git a/src/Aggregation/Inference.php b/src/Aggregation/Inference.php new file mode 100644 index 0000000..12e38fe --- /dev/null +++ b/src/Aggregation/Inference.php @@ -0,0 +1,53 @@ + $bucketsPath Map of input feature => agg path. + * @param array|null $inferenceConfig + */ + public function __construct( + private string $modelId, + private array $bucketsPath, + private array|null $inferenceConfig = null, + private string $key = 'inference', + ) + { + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'model_id' => $this->modelId, + 'buckets_path' => $this->bucketsPath, + ]; + + if ($this->inferenceConfig !== null) { + $array['inference_config'] = $this->inferenceConfig; + } + + return ['inference' => $array]; + } + +} diff --git a/src/Aggregation/IpPrefix.php b/src/Aggregation/IpPrefix.php new file mode 100644 index 0000000..183807b --- /dev/null +++ b/src/Aggregation/IpPrefix.php @@ -0,0 +1,61 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + 'prefix_length' => $this->prefixLength, + ]; + + if ($this->isIpv6 !== null) { + $array['is_ipv6'] = $this->isIpv6; + } + + if ($this->appendPrefixLength !== null) { + $array['append_prefix_length'] = $this->appendPrefixLength; + } + + if ($this->keyed !== null) { + $array['keyed'] = $this->keyed; + } + + if ($this->minDocCount !== null) { + $array['min_doc_count'] = $this->minDocCount; + } + + return ['ip_prefix' => $array]; + } + +} diff --git a/src/Aggregation/MatrixStats.php b/src/Aggregation/MatrixStats.php new file mode 100644 index 0000000..f38a7fa --- /dev/null +++ b/src/Aggregation/MatrixStats.php @@ -0,0 +1,57 @@ + $fields + * @param array|null $missing Per-field missing values. + */ + public function __construct( + private array $fields, + private array|null $missing = null, + private string|null $mode = null, + private string $key = 'matrix_stats', + ) + { + if ($fields === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'MatrixStats requires at least one field.', + ); + } + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['fields' => $this->fields]; + + if ($this->missing !== null) { + $array['missing'] = $this->missing; + } + + if ($this->mode !== null) { + $array['mode'] = $this->mode; + } + + return ['matrix_stats' => $array]; + } + +} diff --git a/src/Aggregation/RandomSampler.php b/src/Aggregation/RandomSampler.php new file mode 100644 index 0000000..2246aa2 --- /dev/null +++ b/src/Aggregation/RandomSampler.php @@ -0,0 +1,48 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = ['probability' => $this->probability]; + + if ($this->seed !== null) { + $array['seed'] = $this->seed; + } + + if ($this->shardSeed !== null) { + $array['shard_seed'] = $this->shardSeed; + } + + return ['random_sampler' => $array]; + } + +} diff --git a/src/Aggregation/Rate.php b/src/Aggregation/Rate.php new file mode 100644 index 0000000..36237cb --- /dev/null +++ b/src/Aggregation/Rate.php @@ -0,0 +1,62 @@ +key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = []; + + if ($this->unit !== null) { + $array['unit'] = $this->unit; + } + + if ($this->field !== null) { + $array['field'] = $this->field; + } + + if ($this->mode !== null) { + $array['mode'] = $this->mode; + } + + if ($this->script !== null) { + $array['script'] = $this->script->toArray(); + } + + return ['rate' => $array]; + } + +} diff --git a/src/Aggregation/TTest.php b/src/Aggregation/TTest.php new file mode 100644 index 0000000..845a0fc --- /dev/null +++ b/src/Aggregation/TTest.php @@ -0,0 +1,52 @@ + $a Population A: ['field' => ..., optional 'filter' => ...] + * @param array $b Population B: ['field' => ..., optional 'filter' => ...] + */ + public function __construct( + private array $a, + private array $b, + private string $type = self::TYPE_HETEROSCEDASTIC, + private string $key = 't_test', + ) + { + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 't_test' => [ + 'a' => $this->a, + 'b' => $this->b, + 'type' => $this->type, + ], + ]; + } + +} diff --git a/src/Aggregation/TimeSeries.php b/src/Aggregation/TimeSeries.php new file mode 100644 index 0000000..da92273 --- /dev/null +++ b/src/Aggregation/TimeSeries.php @@ -0,0 +1,47 @@ +key; + } + + + /** + * @return array|\stdClass> + */ + public function toArray(): array + { + $array = []; + + if ($this->keyed !== null) { + $array['keyed'] = $this->keyed; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + return ['time_series' => $array === [] ? new \stdClass() : $array]; + } + +} diff --git a/src/Aggregation/TopMetrics.php b/src/Aggregation/TopMetrics.php new file mode 100644 index 0000000..1c3facd --- /dev/null +++ b/src/Aggregation/TopMetrics.php @@ -0,0 +1,61 @@ + $metrics Field names to capture. + * @param array>|null $sort + */ + public function __construct( + private array $metrics, + private array|null $sort = null, + private int|null $size = null, + private string $key = 'top_metrics', + ) + { + if ($metrics === []) { + throw new \Spameri\ElasticQuery\Exception\InvalidArgumentException( + 'TopMetrics requires at least one metric field.', + ); + } + } + + + public function key(): string + { + return $this->key; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = []; + + foreach ($this->metrics as $metric) { + $array['metrics'][] = ['field' => $metric]; + } + + if ($this->sort !== null) { + $array['sort'] = $this->sort; + } + + if ($this->size !== null) { + $array['size'] = $this->size; + } + + return ['top_metrics' => $array]; + } + +} diff --git a/src/Aggregation/VariableWidthHistogram.php b/src/Aggregation/VariableWidthHistogram.php new file mode 100644 index 0000000..2f4cb02 --- /dev/null +++ b/src/Aggregation/VariableWidthHistogram.php @@ -0,0 +1,51 @@ +field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $array = [ + 'field' => $this->field, + 'buckets' => $this->buckets, + ]; + + if ($this->shardSize !== null) { + $array['shard_size'] = $this->shardSize; + } + + if ($this->initialBuffer !== null) { + $array['initial_buffer'] = $this->initialBuffer; + } + + return ['variable_width_histogram' => $array]; + } + +} diff --git a/src/Response/ResultMapper.php b/src/Response/ResultMapper.php index 7e31691..7cee416 100644 --- a/src/Response/ResultMapper.php +++ b/src/Response/ResultMapper.php @@ -191,7 +191,13 @@ private function mapAggregation( if (isset($aggregationArray['buckets'])) { foreach ($aggregationArray['buckets'] as $bucketPosition => $bucket) { - $buckets[] = $this->mapBucket($bucketPosition, $bucket); + $bucketArray = \is_array($bucket) ? $bucket : ['doc_count' => 0]; + if (\is_string($bucketPosition)) { + $bucketArray['key'] = $bucketArray['key'] ?? $bucketPosition; + $buckets[] = $this->mapBucket(null, $bucketArray); + } else { + $buckets[] = $this->mapBucket($bucketPosition, $bucketArray); + } } } diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/AutoDateHistogram.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/AutoDateHistogram.phpt new file mode 100644 index 0000000..5616536 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/AutoDateHistogram.phpt @@ -0,0 +1,52 @@ + ['properties' => ['ts' => ['type' => 'date']]]]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\AutoDateHistogram( + field: 'ts', + buckets: 10, + format: 'yyyy-MM-dd', + minimumInterval: 'day', + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('ts', $array['auto_date_histogram']['field']); + \Tester\Assert::same(10, $array['auto_date_histogram']['buckets']); + \Tester\Assert::same('day', $array['auto_date_histogram']['minimum_interval']); + } + + + public function testCreate(): void + { + $this->indexDocument(['ts' => '2024-01-01']); + $this->indexDocument(['ts' => '2024-06-01']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'by_day', null, new \Spameri\ElasticQuery\Aggregation\AutoDateHistogram('ts', buckets: 5), + )); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new AutoDateHistogram())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/CategorizeText.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/CategorizeText.phpt new file mode 100644 index 0000000..5e77bce --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/CategorizeText.phpt @@ -0,0 +1,58 @@ + ['properties' => ['message' => ['type' => 'text']]]]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\CategorizeText( + field: 'message', + maxUniqueTokens: 100, + similarityThreshold: 0.7, + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('message', $array['categorize_text']['field']); + \Tester\Assert::same(100, $array['categorize_text']['max_unique_tokens']); + \Tester\Assert::same(0.7, $array['categorize_text']['similarity_threshold']); + } + + + public function testCreate(): void + { + // categorize_text requires platinum-tier license; skip on basic + $this->indexDocument(['message' => 'Failed to connect to server']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'patterns', null, new \Spameri\ElasticQuery\Aggregation\CategorizeText('message'), + )); + + try { + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } catch (\Spameri\ElasticQuery\Exception\ResponseCouldNotBeMapped $e) { + if (\str_contains($e->getMessage(), 'license')) { + \Tester\Environment::skip('categorize_text requires platinum-tier license'); + } + throw $e; + } + } + +} + +(new CategorizeText())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/CumulativeCardinality.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/CumulativeCardinality.phpt new file mode 100644 index 0000000..43c87d6 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/CumulativeCardinality.phpt @@ -0,0 +1,29 @@ +toArray(); + + \Tester\Assert::same('distinct', $array['cumulative_cardinality']['buckets_path']); + \Tester\Assert::same('0.00', $array['cumulative_cardinality']['format']); + } + +} + +(new CumulativeCardinality())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStatsBucket.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStatsBucket.phpt new file mode 100644 index 0000000..834753a --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/ExtendedStatsBucket.phpt @@ -0,0 +1,32 @@ +sales', + sigma: 2.0, + gapPolicy: 'skip', + format: '0.00', + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('sales_per_month>sales', $array['extended_stats_bucket']['buckets_path']); + \Tester\Assert::same(2.0, $array['extended_stats_bucket']['sigma']); + \Tester\Assert::same('skip', $array['extended_stats_bucket']['gap_policy']); + } + +} + +(new ExtendedStatsBucket())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Filters.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Filters.phpt new file mode 100644 index 0000000..da29e63 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Filters.phpt @@ -0,0 +1,67 @@ + ['properties' => ['status' => ['type' => 'keyword']]]]; + } + + + public function testToArray(): void + { + $filters = new \Spameri\ElasticQuery\Aggregation\Filters(); + $filters->addFilter('a', new \Spameri\ElasticQuery\Query\Term('status', 'a')); + $filters->addFilter('b', new \Spameri\ElasticQuery\Query\Term('status', 'b')); + + $array = $filters->toArray(); + + \Tester\Assert::same('a', $array['filters']['filters']['a']['term']['status']['value']); + \Tester\Assert::same('b', $array['filters']['filters']['b']['term']['status']['value']); + } + + + public function testOtherBucket(): void + { + $filters = new \Spameri\ElasticQuery\Aggregation\Filters( + otherBucket: true, + otherBucketKey: 'rest', + ); + $filters->addFilter('a', new \Spameri\ElasticQuery\Query\Term('status', 'a')); + + $array = $filters->toArray(); + + \Tester\Assert::true($array['filters']['other_bucket']); + \Tester\Assert::same('rest', $array['filters']['other_bucket_key']); + } + + + public function testCreate(): void + { + $this->indexDocument(['status' => 'active']); + $this->indexDocument(['status' => 'inactive']); + + $filters = new \Spameri\ElasticQuery\Aggregation\Filters(); + $filters->addFilter('active', new \Spameri\ElasticQuery\Query\Term('status', 'active')); + $filters->addFilter('inactive', new \Spameri\ElasticQuery\Query\Term('status', 'inactive')); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'by_status', null, $filters, + )); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Filters())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/FrequentItemSets.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/FrequentItemSets.phpt new file mode 100644 index 0000000..3be9cf8 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/FrequentItemSets.phpt @@ -0,0 +1,54 @@ + [ + 'properties' => [ + 'category' => ['type' => 'keyword'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\FrequentItemSets( + fields: [['field' => 'category']], + minimumSupport: 0.1, + minimumSetSize: 2, + size: 10, + ); + + $array = $agg->toArray(); + + \Tester\Assert::same(0.1, $array['frequent_item_sets']['minimum_support']); + \Tester\Assert::same(2, $array['frequent_item_sets']['minimum_set_size']); + } + + + public function testRequiresFields(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\FrequentItemSets([]); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + +} + +(new FrequentItemSets())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/GeoLine.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/GeoLine.phpt new file mode 100644 index 0000000..7e55cc7 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/GeoLine.phpt @@ -0,0 +1,70 @@ + [ + 'properties' => [ + 'location' => ['type' => 'geo_point'], + 'ts' => ['type' => 'date'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\GeoLine( + pointField: 'location', + sortField: 'ts', + sortOrder: 'desc', + includeSort: true, + size: 100, + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('location', $array['geo_line']['point']['field']); + \Tester\Assert::same('ts', $array['geo_line']['sort']['field']); + \Tester\Assert::same('desc', $array['geo_line']['sort_order']); + \Tester\Assert::true($array['geo_line']['include_sort']); + \Tester\Assert::same(100, $array['geo_line']['size']); + } + + + public function testCreate(): void + { + // geo_line requires gold-tier license; skip on basic + $this->indexDocument(['location' => ['lat' => 50, 'lon' => 14], 'ts' => '2024-01-01']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'path', null, new \Spameri\ElasticQuery\Aggregation\GeoLine('location', 'ts'), + )); + + try { + $result = $this->search($elasticQuery); + \Tester\Assert::same(1, $result->stats()->total()); + } catch (\Spameri\ElasticQuery\Exception\ResponseCouldNotBeMapped $e) { + if (\str_contains($e->getMessage(), 'license')) { + \Tester\Environment::skip('geo_line aggregation requires gold-tier license'); + } + throw $e; + } + } + +} + +(new GeoLine())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Inference.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Inference.phpt new file mode 100644 index 0000000..5236b47 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Inference.phpt @@ -0,0 +1,30 @@ + 'avg_value'], + inferenceConfig: ['regression' => ['results_field' => 'prediction']], + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('my_model', $array['inference']['model_id']); + \Tester\Assert::same(['feature' => 'avg_value'], $array['inference']['buckets_path']); + } + +} + +(new Inference())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/IpPrefix.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/IpPrefix.phpt new file mode 100644 index 0000000..9bfa249 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/IpPrefix.phpt @@ -0,0 +1,55 @@ + ['properties' => ['ip' => ['type' => 'ip']]]]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\IpPrefix( + field: 'ip', + prefixLength: 16, + appendPrefixLength: true, + keyed: false, + minDocCount: 1, + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('ip', $array['ip_prefix']['field']); + \Tester\Assert::same(16, $array['ip_prefix']['prefix_length']); + \Tester\Assert::true($array['ip_prefix']['append_prefix_length']); + \Tester\Assert::same(1, $array['ip_prefix']['min_doc_count']); + } + + + public function testCreate(): void + { + $this->indexDocument(['ip' => '10.0.0.1']); + $this->indexDocument(['ip' => '10.0.5.20']); + $this->indexDocument(['ip' => '192.168.0.1']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'by_prefix', null, new \Spameri\ElasticQuery\Aggregation\IpPrefix('ip', 8), + )); + + \Tester\Assert::same(3, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new IpPrefix())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/MatrixStats.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/MatrixStats.phpt new file mode 100644 index 0000000..36382aa --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/MatrixStats.phpt @@ -0,0 +1,68 @@ + [ + 'properties' => [ + 'income' => ['type' => 'long'], + 'expense' => ['type' => 'long'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\MatrixStats( + fields: ['income', 'expense'], + missing: ['income' => 0], + mode: 'avg', + ); + + $array = $agg->toArray(); + + \Tester\Assert::same(['income', 'expense'], $array['matrix_stats']['fields']); + \Tester\Assert::same('avg', $array['matrix_stats']['mode']); + } + + + public function testRequiresFields(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\MatrixStats([]); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void + { + $this->indexDocument(['income' => 100, 'expense' => 50]); + $this->indexDocument(['income' => 200, 'expense' => 80]); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'corr', null, new \Spameri\ElasticQuery\Aggregation\MatrixStats(['income', 'expense']), + )); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new MatrixStats())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/RandomSampler.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/RandomSampler.phpt new file mode 100644 index 0000000..63e3f11 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/RandomSampler.phpt @@ -0,0 +1,29 @@ +toArray(); + + \Tester\Assert::same(0.1, $array['random_sampler']['probability']); + \Tester\Assert::same(42, $array['random_sampler']['seed']); + } + +} + +(new RandomSampler())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/Rate.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/Rate.phpt new file mode 100644 index 0000000..df607a8 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/Rate.phpt @@ -0,0 +1,76 @@ + [ + 'properties' => [ + 'ts' => ['type' => 'date'], + 'amount' => ['type' => 'long'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\Rate( + unit: 'month', + field: 'amount', + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('month', $array['rate']['unit']); + \Tester\Assert::same('amount', $array['rate']['field']); + } + + + public function testRequiresUnitOrScript(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\Rate(); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void + { + $this->indexDocument(['ts' => '2024-01-01', 'amount' => 10]); + $this->indexDocument(['ts' => '2024-02-01', 'amount' => 20]); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + // rate aggregation must live inside a date_histogram + $rateLeaf = new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'monthly_rate', + null, + new \Spameri\ElasticQuery\Aggregation\Rate(unit: 'month', field: 'amount'), + ); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'by_month', + null, + new \Spameri\ElasticQuery\Aggregation\DateHistogram('ts', calendarInterval: 'month'), + $rateLeaf, + )); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Rate())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/TTest.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/TTest.phpt new file mode 100644 index 0000000..fbedf76 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/TTest.phpt @@ -0,0 +1,61 @@ + [ + 'properties' => [ + 'pre' => ['type' => 'long'], + 'post' => ['type' => 'long'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\TTest( + a: ['field' => 'pre'], + b: ['field' => 'post'], + type: \Spameri\ElasticQuery\Aggregation\TTest::TYPE_PAIRED, + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('paired', $array['t_test']['type']); + \Tester\Assert::same('pre', $array['t_test']['a']['field']); + } + + + public function testCreate(): void + { + $this->indexDocument(['pre' => 10, 'post' => 12]); + $this->indexDocument(['pre' => 20, 'post' => 25]); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'change', null, new \Spameri\ElasticQuery\Aggregation\TTest( + ['field' => 'pre'], + ['field' => 'post'], + \Spameri\ElasticQuery\Aggregation\TTest::TYPE_PAIRED, + ), + )); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new TTest())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/TimeSeries.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/TimeSeries.phpt new file mode 100644 index 0000000..c99f015 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/TimeSeries.phpt @@ -0,0 +1,39 @@ +toArray(); + + \Tester\Assert::type(\stdClass::class, $array['time_series']); + } + + + public function testToArrayWithOptions(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\TimeSeries( + keyed: true, + size: 100, + ); + + $array = $agg->toArray(); + + \Tester\Assert::true($array['time_series']['keyed']); + \Tester\Assert::same(100, $array['time_series']['size']); + } + +} + +(new TimeSeries())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/TopMetrics.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/TopMetrics.phpt new file mode 100644 index 0000000..b0ba0bb --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/TopMetrics.phpt @@ -0,0 +1,72 @@ + [ + 'properties' => [ + 'price' => ['type' => 'long'], + 'ts' => ['type' => 'date'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\TopMetrics( + metrics: ['price'], + sort: [['ts' => ['order' => 'desc']]], + size: 1, + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('price', $array['top_metrics']['metrics'][0]['field']); + \Tester\Assert::same(1, $array['top_metrics']['size']); + } + + + public function testRequiresMetrics(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Aggregation\TopMetrics([]); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void + { + $this->indexDocument(['price' => 100, 'ts' => '2024-01-01']); + $this->indexDocument(['price' => 200, 'ts' => '2024-06-01']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'latest_price', null, new \Spameri\ElasticQuery\Aggregation\TopMetrics( + ['price'], + [['ts' => ['order' => 'desc']]], + 1, + ), + )); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new TopMetrics())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Aggregation/VariableWidthHistogram.phpt b/tests/SpameriTests/ElasticQuery/Aggregation/VariableWidthHistogram.phpt new file mode 100644 index 0000000..eba1d7a --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Aggregation/VariableWidthHistogram.phpt @@ -0,0 +1,54 @@ + ['properties' => ['price' => ['type' => 'long']]]]; + } + + + public function testToArray(): void + { + $agg = new \Spameri\ElasticQuery\Aggregation\VariableWidthHistogram( + field: 'price', + buckets: 3, + shardSize: 10, + initialBuffer: 100, + ); + + $array = $agg->toArray(); + + \Tester\Assert::same('price', $array['variable_width_histogram']['field']); + \Tester\Assert::same(3, $array['variable_width_histogram']['buckets']); + \Tester\Assert::same(10, $array['variable_width_histogram']['shard_size']); + \Tester\Assert::same(100, $array['variable_width_histogram']['initial_buffer']); + } + + + public function testCreate(): void + { + $this->indexDocument(['price' => 10]); + $this->indexDocument(['price' => 50]); + $this->indexDocument(['price' => 100]); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->aggregation()->add(new \Spameri\ElasticQuery\Aggregation\LeafAggregationCollection( + 'price_buckets', null, new \Spameri\ElasticQuery\Aggregation\VariableWidthHistogram('price', 2), + )); + + \Tester\Assert::same(3, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new VariableWidthHistogram())->run(); From 7283bb64a404a055d366242b2611757e0a39908d Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:44:02 +0200 Subject: [PATCH 91/97] feat(function-score): decay functions + ScriptScore + boost_mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New decay score functions (numeric/date/geo): - FunctionScore/ScoreFunction/Decay/Gauss - FunctionScore/ScoreFunction/Decay/Linear - FunctionScore/ScoreFunction/Decay/Exp - Shared AbstractDecay parent with field/origin/scale/offset/decay/ multi_value_mode args New FunctionScore/ScoreFunction/ScriptScore — distinct from the top-level Query/ScriptScore. Wraps a Script value object. FunctionScore container gains boost, boost_mode, max_boost, min_score plus BOOST_MODE_* constants. boost_mode and score_mode are different things; boost_mode controls how the function score combines with the query score, score_mode controls how multiple functions combine. Integration tests round-trip the new functions against ES, including a geo_point Gauss decay over a real distance. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/FunctionScore.php | 32 +++++ .../ScoreFunction/Decay/AbstractDecay.php | 64 +++++++++ src/FunctionScore/ScoreFunction/Decay/Exp.php | 16 +++ .../ScoreFunction/Decay/Gauss.php | 16 +++ .../ScoreFunction/Decay/Linear.php | 16 +++ .../ScoreFunction/ScriptScore.php | 44 +++++++ .../ElasticQuery/FunctionScore/Decay.phpt | 122 ++++++++++++++++++ .../FunctionScore/ScriptScore.phpt | 52 ++++++++ 8 files changed, 362 insertions(+) create mode 100644 src/FunctionScore/ScoreFunction/Decay/AbstractDecay.php create mode 100644 src/FunctionScore/ScoreFunction/Decay/Exp.php create mode 100644 src/FunctionScore/ScoreFunction/Decay/Gauss.php create mode 100644 src/FunctionScore/ScoreFunction/Decay/Linear.php create mode 100644 src/FunctionScore/ScoreFunction/ScriptScore.php create mode 100644 tests/SpameriTests/ElasticQuery/FunctionScore/Decay.phpt create mode 100644 tests/SpameriTests/ElasticQuery/FunctionScore/ScriptScore.phpt diff --git a/src/FunctionScore.php b/src/FunctionScore.php index a853b96..6289a3e 100644 --- a/src/FunctionScore.php +++ b/src/FunctionScore.php @@ -4,6 +4,7 @@ namespace Spameri\ElasticQuery; + class FunctionScore { @@ -14,11 +15,22 @@ class FunctionScore public const SCORE_MODE_MAX = 'max'; public const SCORE_MODE_MIN = 'min'; + public const BOOST_MODE_MULTIPLY = 'multiply'; + public const BOOST_MODE_REPLACE = 'replace'; + public const BOOST_MODE_SUM = 'sum'; + public const BOOST_MODE_AVG = 'avg'; + public const BOOST_MODE_MAX = 'max'; + public const BOOST_MODE_MIN = 'min'; + private \Spameri\ElasticQuery\FunctionScore\FunctionScoreCollection $function; public function __construct( \Spameri\ElasticQuery\FunctionScore\FunctionScoreCollection|null $function = null, private string|null $scoreMode = null, + private string|null $boostMode = null, + private float|null $boost = null, + private float|null $maxBoost = null, + private float|null $minScore = null, ) { $this->function = $function ?? new \Spameri\ElasticQuery\FunctionScore\FunctionScoreCollection(); @@ -37,6 +49,10 @@ public function scoreMode(): string|null } + /** + * @param array $queryPart + * @return array> + */ public function toArray(array $queryPart): array { $functions = []; @@ -55,6 +71,22 @@ public function toArray(array $queryPart): array $array['function_score']['score_mode'] = $this->scoreMode; } + if ($this->boostMode !== null) { + $array['function_score']['boost_mode'] = $this->boostMode; + } + + if ($this->boost !== null) { + $array['function_score']['boost'] = $this->boost; + } + + if ($this->maxBoost !== null) { + $array['function_score']['max_boost'] = $this->maxBoost; + } + + if ($this->minScore !== null) { + $array['function_score']['min_score'] = $this->minScore; + } + return $array; } diff --git a/src/FunctionScore/ScoreFunction/Decay/AbstractDecay.php b/src/FunctionScore/ScoreFunction/Decay/AbstractDecay.php new file mode 100644 index 0000000..9778426 --- /dev/null +++ b/src/FunctionScore/ScoreFunction/Decay/AbstractDecay.php @@ -0,0 +1,64 @@ +name() . '_' . $this->field; + } + + + /** + * @return array> + */ + public function toArray(): array + { + $fieldBody = [ + 'origin' => $this->origin, + 'scale' => $this->scale, + ]; + + if ($this->offset !== null) { + $fieldBody['offset'] = $this->offset; + } + + if ($this->decay !== null) { + $fieldBody['decay'] = $this->decay; + } + + $body = [$this->field => $fieldBody]; + + if ($this->multiValueMode !== null) { + $body['multi_value_mode'] = $this->multiValueMode; + } + + return [$this->name() => $body]; + } + +} diff --git a/src/FunctionScore/ScoreFunction/Decay/Exp.php b/src/FunctionScore/ScoreFunction/Decay/Exp.php new file mode 100644 index 0000000..b01842f --- /dev/null +++ b/src/FunctionScore/ScoreFunction/Decay/Exp.php @@ -0,0 +1,16 @@ +name ?? \spl_object_hash($this)); + } + + + /** + * @return array> + */ + public function toArray(): array + { + return [ + 'script_score' => [ + 'script' => $this->script->toArray(), + ], + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/FunctionScore/Decay.phpt b/tests/SpameriTests/ElasticQuery/FunctionScore/Decay.phpt new file mode 100644 index 0000000..21c2643 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/FunctionScore/Decay.phpt @@ -0,0 +1,122 @@ + [ + 'properties' => [ + 'location' => ['type' => 'geo_point'], + 'price' => ['type' => 'long'], + ], + ], + ]; + } + + + public function testGaussToArray(): void + { + $gauss = new \Spameri\ElasticQuery\FunctionScore\ScoreFunction\Decay\Gauss( + field: 'price', + origin: 100, + scale: 50, + offset: 5, + decay: 0.5, + multiValueMode: 'avg', + ); + + $array = $gauss->toArray(); + + \Tester\Assert::same(100, $array['gauss']['price']['origin']); + \Tester\Assert::same(50, $array['gauss']['price']['scale']); + \Tester\Assert::same(5, $array['gauss']['price']['offset']); + \Tester\Assert::same(0.5, $array['gauss']['price']['decay']); + \Tester\Assert::same('avg', $array['gauss']['multi_value_mode']); + } + + + public function testLinearToArray(): void + { + $linear = new \Spameri\ElasticQuery\FunctionScore\ScoreFunction\Decay\Linear( + field: 'price', + origin: 100, + scale: 50, + ); + + \Tester\Assert::same(100, $linear->toArray()['linear']['price']['origin']); + } + + + public function testExpToArray(): void + { + $exp = new \Spameri\ElasticQuery\FunctionScore\ScoreFunction\Decay\Exp( + field: 'price', + origin: 100, + scale: 50, + ); + + \Tester\Assert::same(100, $exp->toArray()['exp']['price']['origin']); + } + + + public function testCreateGauss(): void + { + $this->indexDocument(['price' => 100]); + $this->indexDocument(['price' => 1000]); + + $gauss = new \Spameri\ElasticQuery\FunctionScore\ScoreFunction\Decay\Gauss( + field: 'price', + origin: 100, + scale: 50, + ); + + $functionScore = new \Spameri\ElasticQuery\FunctionScore( + new \Spameri\ElasticQuery\FunctionScore\FunctionScoreCollection($gauss), + scoreMode: \Spameri\ElasticQuery\FunctionScore::SCORE_MODE_MULTIPLY, + boostMode: \Spameri\ElasticQuery\FunctionScore::BOOST_MODE_REPLACE, + boost: 1.0, + maxBoost: 10.0, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(functionScore: $functionScore); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + + + public function testCreateGeoGauss(): void + { + $this->indexDocument(['location' => ['lat' => 50, 'lon' => 14]]); + + $gauss = new \Spameri\ElasticQuery\FunctionScore\ScoreFunction\Decay\Gauss( + field: 'location', + origin: ['lat' => 50, 'lon' => 14], + scale: '10km', + offset: '1km', + decay: 0.5, + ); + + $functionScore = new \Spameri\ElasticQuery\FunctionScore( + new \Spameri\ElasticQuery\FunctionScore\FunctionScoreCollection($gauss), + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(functionScore: $functionScore); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Decay())->run(); diff --git a/tests/SpameriTests/ElasticQuery/FunctionScore/ScriptScore.phpt b/tests/SpameriTests/ElasticQuery/FunctionScore/ScriptScore.phpt new file mode 100644 index 0000000..b7f0207 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/FunctionScore/ScriptScore.phpt @@ -0,0 +1,52 @@ + ['properties' => ['price' => ['type' => 'long']]]]; + } + + + public function testToArray(): void + { + $ss = new \Spameri\ElasticQuery\FunctionScore\ScoreFunction\ScriptScore( + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].value * 2"), + ); + + $array = $ss->toArray(); + + \Tester\Assert::same("doc['price'].value * 2", $array['script_score']['script']['source']); + } + + + public function testCreate(): void + { + $this->indexDocument(['price' => 100]); + + $ss = new \Spameri\ElasticQuery\FunctionScore\ScoreFunction\ScriptScore( + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].value * 2"), + ); + + $functionScore = new \Spameri\ElasticQuery\FunctionScore( + new \Spameri\ElasticQuery\FunctionScore\FunctionScoreCollection($ss), + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(functionScore: $functionScore); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new ScriptScore())->run(); From 439457300d2cedf52c6d677f16de5eaac202272d Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:46:32 +0200 Subject: [PATCH 92/97] feat(options): Sort expansion + ScriptSort + NestedSort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort: mode (avg/min/max/sum/median), nested (NestedSort), numeric_type, unmapped_type, format. Removed readonly so script_sort etc can coexist in the SortCollection without subclass restrictions. - ScriptSort: new — sorts by a Spameri\ElasticQuery\Script with type (number/string), order, mode, nested. Emits _script body. - NestedSort: new sub-object — path, filter (LeafQueryInterface), max_children, recursive nested. Options/SortCollection emit logic updated to handle non-Sort items (ScriptSort/GeoDistanceSort) — _score short-circuit only fires for plain Sort. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Options.php | 2 +- src/Options/NestedSort.php | 48 ++++++++++ src/Options/ScriptSort.php | 66 +++++++++++++ src/Options/Sort.php | 40 +++++++- src/Options/SortCollection.php | 3 +- .../ElasticQuery/Options/NestedSort.phpt | 93 +++++++++++++++++++ .../ElasticQuery/Options/ScriptSort.phpt | 69 ++++++++++++++ 7 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 src/Options/NestedSort.php create mode 100644 src/Options/ScriptSort.php create mode 100644 tests/SpameriTests/ElasticQuery/Options/NestedSort.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Options/ScriptSort.phpt diff --git a/src/Options.php b/src/Options.php index 7bcd69d..d1f9345 100644 --- a/src/Options.php +++ b/src/Options.php @@ -84,7 +84,7 @@ public function toArray(): array } foreach ($this->sort as $item) { - if ($item->field === '_score') { + if ($item instanceof \Spameri\ElasticQuery\Options\Sort && $item->field === '_score') { $array['sort'][] = $item->field; continue; } diff --git a/src/Options/NestedSort.php b/src/Options/NestedSort.php new file mode 100644 index 0000000..b4f45c3 --- /dev/null +++ b/src/Options/NestedSort.php @@ -0,0 +1,48 @@ + + */ + public function toArray(): array + { + $array = ['path' => $this->path]; + + if ($this->filter !== null) { + $array['filter'] = $this->filter->toArray(); + } + + if ($this->maxChildren !== null) { + $array['max_children'] = $this->maxChildren; + } + + if ($this->nested !== null) { + $array['nested'] = $this->nested->toArray(); + } + + return $array; + } + +} diff --git a/src/Options/ScriptSort.php b/src/Options/ScriptSort.php new file mode 100644 index 0000000..6b436f5 --- /dev/null +++ b/src/Options/ScriptSort.php @@ -0,0 +1,66 @@ +> + */ + public function toArray(): array + { + $body = [ + 'type' => $this->type, + 'script' => $this->script->toArray(), + 'order' => $this->order, + ]; + + if ($this->mode !== null) { + $body['mode'] = $this->mode; + } + + if ($this->nested !== null) { + $body['nested'] = $this->nested->toArray(); + } + + return ['_script' => $body]; + } + +} diff --git a/src/Options/Sort.php b/src/Options/Sort.php index e917515..0ecb5a8 100644 --- a/src/Options/Sort.php +++ b/src/Options/Sort.php @@ -6,7 +6,7 @@ /** - * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html */ class Sort implements \Spameri\ElasticQuery\Entity\EntityInterface { @@ -20,6 +20,11 @@ public function __construct( public string $field, public string $type = self::DESC, public string $missing = self::MISSING_LAST, + public string|null $mode = null, + public \Spameri\ElasticQuery\Options\NestedSort|null $nested = null, + public string|null $numericType = null, + public string|null $unmappedType = null, + public string|null $format = null, ) { if ( ! \in_array($type, [self::ASC, self::DESC], true)) { @@ -42,13 +47,38 @@ public function key(): string } + /** + * @return array> + */ public function toArray(): array { + $body = [ + 'order' => $this->type, + 'missing' => $this->missing, + ]; + + if ($this->mode !== null) { + $body['mode'] = $this->mode; + } + + if ($this->nested !== null) { + $body['nested'] = $this->nested->toArray(); + } + + if ($this->numericType !== null) { + $body['numeric_type'] = $this->numericType; + } + + if ($this->unmappedType !== null) { + $body['unmapped_type'] = $this->unmappedType; + } + + if ($this->format !== null) { + $body['format'] = $this->format; + } + return [ - $this->field => [ - 'order' => $this->type, - 'missing' => $this->missing, - ], + $this->field => $body, ]; } diff --git a/src/Options/SortCollection.php b/src/Options/SortCollection.php index e3ef29c..2b36570 100644 --- a/src/Options/SortCollection.php +++ b/src/Options/SortCollection.php @@ -12,9 +12,8 @@ public function toArray(): array { $array = []; - /** @var \Spameri\ElasticQuery\Options\Sort $sort */ foreach ($this->collection as $sort) { - if ($sort->field === '_score') { + if ($sort instanceof \Spameri\ElasticQuery\Options\Sort && $sort->field === '_score') { $array[] = $sort->field; continue; } diff --git a/tests/SpameriTests/ElasticQuery/Options/NestedSort.phpt b/tests/SpameriTests/ElasticQuery/Options/NestedSort.phpt new file mode 100644 index 0000000..5e06d5b --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Options/NestedSort.phpt @@ -0,0 +1,93 @@ + [ + 'properties' => [ + 'comments' => [ + 'type' => 'nested', + 'properties' => [ + 'rating' => ['type' => 'long'], + 'author' => ['type' => 'keyword'], + ], + ], + ], + ], + ]; + } + + + public function testToArray(): void + { + $nestedSort = new \Spameri\ElasticQuery\Options\NestedSort( + path: 'comments', + filter: new \Spameri\ElasticQuery\Query\Term('comments.author', 'john'), + maxChildren: 5, + ); + + $array = $nestedSort->toArray(); + + \Tester\Assert::same('comments', $array['path']); + \Tester\Assert::same(5, $array['max_children']); + \Tester\Assert::same('john', $array['filter']['term']['comments.author']['value']); + } + + + public function testSortWithNested(): void + { + $nestedSort = new \Spameri\ElasticQuery\Options\NestedSort( + path: 'comments', + ); + $sort = new \Spameri\ElasticQuery\Options\Sort( + field: 'comments.rating', + type: \Spameri\ElasticQuery\Options\Sort::DESC, + mode: 'avg', + nested: $nestedSort, + ); + + $array = $sort->toArray(); + + \Tester\Assert::same('avg', $array['comments.rating']['mode']); + \Tester\Assert::same('comments', $array['comments.rating']['nested']['path']); + } + + + public function testCreate(): void + { + $this->indexDocument([ + 'comments' => [ + ['author' => 'john', 'rating' => 5], + ['author' => 'jane', 'rating' => 3], + ], + ]); + + $nestedSort = new \Spameri\ElasticQuery\Options\NestedSort(path: 'comments'); + $sort = new \Spameri\ElasticQuery\Options\Sort( + field: 'comments.rating', + type: \Spameri\ElasticQuery\Options\Sort::DESC, + mode: 'avg', + nested: $nestedSort, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->options()->sort()->add($sort); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new NestedSort())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Options/ScriptSort.phpt b/tests/SpameriTests/ElasticQuery/Options/ScriptSort.phpt new file mode 100644 index 0000000..0657661 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Options/ScriptSort.phpt @@ -0,0 +1,69 @@ + ['properties' => ['price' => ['type' => 'long']]]]; + } + + + public function testToArray(): void + { + $sort = new \Spameri\ElasticQuery\Options\ScriptSort( + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].value"), + type: \Spameri\ElasticQuery\Options\ScriptSort::TYPE_NUMBER, + order: \Spameri\ElasticQuery\Options\Sort::DESC, + ); + + $array = $sort->toArray(); + + \Tester\Assert::same('number', $array['_script']['type']); + \Tester\Assert::same('DESC', $array['_script']['order']); + \Tester\Assert::same("doc['price'].value", $array['_script']['script']['source']); + } + + + public function testRejectsInvalidType(): void + { + \Tester\Assert::exception( + static function (): void { + new \Spameri\ElasticQuery\Options\ScriptSort( + script: new \Spameri\ElasticQuery\Script(source: ''), + type: 'invalid', + ); + }, + \Spameri\ElasticQuery\Exception\InvalidArgumentException::class, + ); + } + + + public function testCreate(): void + { + $this->indexDocument(['price' => 100]); + $this->indexDocument(['price' => 50]); + + $scriptSort = new \Spameri\ElasticQuery\Options\ScriptSort( + script: new \Spameri\ElasticQuery\Script(source: "doc['price'].value"), + order: \Spameri\ElasticQuery\Options\Sort::DESC, + ); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery(); + $elasticQuery->options()->sort()->add($scriptSort); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new ScriptSort())->run(); From cad64aa98dd630ea3b3b8bfb958ae099ad74db09 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:48:34 +0200 Subject: [PATCH 93/97] feat(highlight): rewrite with HighlightField + global options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old Highlight class hard-coded number_of_fragments to 0 and exposed no per-field configuration. Rewritten: - Highlight/HighlightField — per-field config (type, number_of_fragments, fragment_size, boundary_scanner, boundary_chars, boundary_max_scan, boundary_scanner_locale, encoder, force_source, fragmenter, highlight_query, matched_fields, no_match_size, order, phrase_limit, require_field_match, tags_schema, pre_tags, post_tags). - Highlight/HighlightFieldCollection — typed collection of fields. - Highlight — accepts either HighlightFieldCollection or a simple array of field names (BC convenience). Adds all top-level options (type, fragment_size, boundary_*, encoder, force_source, fragmenter, highlight_query, matched_fields, no_match_size, order, phrase_limit, require_field_match, tags_schema). Existing test still passes — the simple string-array path is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Highlight.php | 125 +++++++++++++++- src/Highlight/HighlightField.php | 139 ++++++++++++++++++ src/Highlight/HighlightFieldCollection.php | 25 ++++ .../Highlight/HighlightField.phpt | 91 ++++++++++++ 4 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 src/Highlight/HighlightField.php create mode 100644 src/Highlight/HighlightFieldCollection.php create mode 100644 tests/SpameriTests/ElasticQuery/Highlight/HighlightField.phpt diff --git a/src/Highlight.php b/src/Highlight.php index 3823c05..82998b2 100644 --- a/src/Highlight.php +++ b/src/Highlight.php @@ -4,18 +4,68 @@ namespace Spameri\ElasticQuery; + +/** + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html + */ class Highlight implements \Spameri\ElasticQuery\Entity\ArrayInterface { + private \Spameri\ElasticQuery\Highlight\HighlightFieldCollection $fields; + + + /** + * @param array $preTags + * @param array $postTags + * @param \Spameri\ElasticQuery\Highlight\HighlightFieldCollection|array $fields Either a typed collection or simple field-name list. + * @param array|null $matchedFields + */ public function __construct( private array $preTags, private array $postTags, - private array $fields, + \Spameri\ElasticQuery\Highlight\HighlightFieldCollection|array $fields, + private string|null $type = null, + int|null $numberOfFragments = 0, + private int|null $fragmentSize = null, + private string|null $boundaryScanner = null, + private string|null $boundaryChars = null, + private int|null $boundaryMaxScan = null, + private string|null $boundaryScannerLocale = null, + private string|null $encoder = null, + private bool|null $forceSource = null, + private string|null $fragmenter = null, + private \Spameri\ElasticQuery\Query\LeafQueryInterface|null $highlightQuery = null, + private array|null $matchedFields = null, + private int|null $noMatchSize = null, + private string|null $order = null, + private int|null $phraseLimit = null, + private bool|null $requireFieldMatch = null, + private string|null $tagsSchema = null, ) { + if ($fields instanceof \Spameri\ElasticQuery\Highlight\HighlightFieldCollection) { + $this->fields = $fields; + } else { + $this->fields = new \Spameri\ElasticQuery\Highlight\HighlightFieldCollection(); + foreach ($fields as $fieldName) { + $this->fields->add(new \Spameri\ElasticQuery\Highlight\HighlightField( + field: $fieldName, + numberOfFragments: $numberOfFragments, + )); + } + } } + public function fields(): \Spameri\ElasticQuery\Highlight\HighlightFieldCollection + { + return $this->fields; + } + + + /** + * @return array + */ public function toArray(): array { $array = [ @@ -23,10 +73,75 @@ public function toArray(): array 'post_tags' => $this->postTags, ]; - foreach ($this->fields as $key) { - $array['fields'][$key] = [ - 'number_of_fragments' => 0, - ]; + $fieldsArray = $this->fields->toArray(); + if ($fieldsArray !== []) { + $array['fields'] = $fieldsArray; + } + + if ($this->type !== null) { + $array['type'] = $this->type; + } + + // global number_of_fragments is mirrored into each field above; we don't emit it twice + + if ($this->fragmentSize !== null) { + $array['fragment_size'] = $this->fragmentSize; + } + + if ($this->boundaryScanner !== null) { + $array['boundary_scanner'] = $this->boundaryScanner; + } + + if ($this->boundaryChars !== null) { + $array['boundary_chars'] = $this->boundaryChars; + } + + if ($this->boundaryMaxScan !== null) { + $array['boundary_max_scan'] = $this->boundaryMaxScan; + } + + if ($this->boundaryScannerLocale !== null) { + $array['boundary_scanner_locale'] = $this->boundaryScannerLocale; + } + + if ($this->encoder !== null) { + $array['encoder'] = $this->encoder; + } + + if ($this->forceSource !== null) { + $array['force_source'] = $this->forceSource; + } + + if ($this->fragmenter !== null) { + $array['fragmenter'] = $this->fragmenter; + } + + if ($this->highlightQuery !== null) { + $array['highlight_query'] = $this->highlightQuery->toArray(); + } + + if ($this->matchedFields !== null) { + $array['matched_fields'] = $this->matchedFields; + } + + if ($this->noMatchSize !== null) { + $array['no_match_size'] = $this->noMatchSize; + } + + if ($this->order !== null) { + $array['order'] = $this->order; + } + + if ($this->phraseLimit !== null) { + $array['phrase_limit'] = $this->phraseLimit; + } + + if ($this->requireFieldMatch !== null) { + $array['require_field_match'] = $this->requireFieldMatch; + } + + if ($this->tagsSchema !== null) { + $array['tags_schema'] = $this->tagsSchema; } return $array; diff --git a/src/Highlight/HighlightField.php b/src/Highlight/HighlightField.php new file mode 100644 index 0000000..7477b43 --- /dev/null +++ b/src/Highlight/HighlightField.php @@ -0,0 +1,139 @@ +|null $preTags + * @param array|null $postTags + * @param array|null $matchedFields + */ + public function __construct( + private string $field, + private string|null $type = null, + private int|null $numberOfFragments = null, + private int|null $fragmentSize = null, + private string|null $boundaryScanner = null, + private string|null $boundaryChars = null, + private int|null $boundaryMaxScan = null, + private string|null $boundaryScannerLocale = null, + private string|null $encoder = null, + private bool|null $forceSource = null, + private string|null $fragmenter = null, + private \Spameri\ElasticQuery\Query\LeafQueryInterface|null $highlightQuery = null, + private array|null $matchedFields = null, + private int|null $noMatchSize = null, + private string|null $order = null, + private int|null $phraseLimit = null, + private bool|null $requireFieldMatch = null, + private string|null $tagsSchema = null, + private array|null $preTags = null, + private array|null $postTags = null, + ) + { + } + + + public function key(): string + { + return $this->field; + } + + + /** + * @return array + */ + public function toArray(): array + { + $array = []; + + if ($this->type !== null) { + $array['type'] = $this->type; + } + + if ($this->numberOfFragments !== null) { + $array['number_of_fragments'] = $this->numberOfFragments; + } + + if ($this->fragmentSize !== null) { + $array['fragment_size'] = $this->fragmentSize; + } + + if ($this->boundaryScanner !== null) { + $array['boundary_scanner'] = $this->boundaryScanner; + } + + if ($this->boundaryChars !== null) { + $array['boundary_chars'] = $this->boundaryChars; + } + + if ($this->boundaryMaxScan !== null) { + $array['boundary_max_scan'] = $this->boundaryMaxScan; + } + + if ($this->boundaryScannerLocale !== null) { + $array['boundary_scanner_locale'] = $this->boundaryScannerLocale; + } + + if ($this->encoder !== null) { + $array['encoder'] = $this->encoder; + } + + if ($this->forceSource !== null) { + $array['force_source'] = $this->forceSource; + } + + if ($this->fragmenter !== null) { + $array['fragmenter'] = $this->fragmenter; + } + + if ($this->highlightQuery !== null) { + $array['highlight_query'] = $this->highlightQuery->toArray(); + } + + if ($this->matchedFields !== null) { + $array['matched_fields'] = $this->matchedFields; + } + + if ($this->noMatchSize !== null) { + $array['no_match_size'] = $this->noMatchSize; + } + + if ($this->order !== null) { + $array['order'] = $this->order; + } + + if ($this->phraseLimit !== null) { + $array['phrase_limit'] = $this->phraseLimit; + } + + if ($this->requireFieldMatch !== null) { + $array['require_field_match'] = $this->requireFieldMatch; + } + + if ($this->tagsSchema !== null) { + $array['tags_schema'] = $this->tagsSchema; + } + + if ($this->preTags !== null) { + $array['pre_tags'] = $this->preTags; + } + + if ($this->postTags !== null) { + $array['post_tags'] = $this->postTags; + } + + return $array; + } + +} diff --git a/src/Highlight/HighlightFieldCollection.php b/src/Highlight/HighlightFieldCollection.php new file mode 100644 index 0000000..4105056 --- /dev/null +++ b/src/Highlight/HighlightFieldCollection.php @@ -0,0 +1,25 @@ +> + */ + public function toArray(): array + { + $array = []; + foreach ($this->collection as $field) { + \assert($field instanceof HighlightField); + $array[$field->key()] = $field->toArray(); + } + + return $array; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Highlight/HighlightField.phpt b/tests/SpameriTests/ElasticQuery/Highlight/HighlightField.phpt new file mode 100644 index 0000000..7af981d --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Highlight/HighlightField.phpt @@ -0,0 +1,91 @@ + [ + 'properties' => [ + 'title' => ['type' => 'text'], + 'body' => ['type' => 'text'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $field = new \Spameri\ElasticQuery\Highlight\HighlightField( + field: 'title', + type: 'unified', + numberOfFragments: 3, + fragmentSize: 150, + boundaryScanner: 'sentence', + encoder: 'html', + fragmenter: 'span', + noMatchSize: 100, + order: 'score', + phraseLimit: 256, + requireFieldMatch: false, + preTags: [''], + postTags: [''], + ); + + $array = $field->toArray(); + + \Tester\Assert::same('unified', $array['type']); + \Tester\Assert::same(3, $array['number_of_fragments']); + \Tester\Assert::same(150, $array['fragment_size']); + \Tester\Assert::same('sentence', $array['boundary_scanner']); + \Tester\Assert::same('html', $array['encoder']); + \Tester\Assert::same('span', $array['fragmenter']); + \Tester\Assert::same(100, $array['no_match_size']); + \Tester\Assert::same('score', $array['order']); + \Tester\Assert::same(256, $array['phrase_limit']); + \Tester\Assert::false($array['require_field_match']); + \Tester\Assert::same([''], $array['pre_tags']); + } + + + public function testCreate(): void + { + $this->indexDocument(['title' => 'quick brown fox', 'body' => 'jumps over the lazy dog']); + + $fields = new \Spameri\ElasticQuery\Highlight\HighlightFieldCollection(); + $fields->add(new \Spameri\ElasticQuery\Highlight\HighlightField( + field: 'title', + numberOfFragments: 0, + type: 'unified', + )); + $fields->add(new \Spameri\ElasticQuery\Highlight\HighlightField( + field: 'body', + numberOfFragments: 3, + fragmentSize: 50, + )); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery( + highlight: new \Spameri\ElasticQuery\Highlight( + preTags: [''], + postTags: [''], + fields: $fields, + ), + ); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\ElasticMatch('title', 'fox')); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new HighlightField())->run(); From 958e306b59f55653eeede6f0ba325d8cfb943d65 Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:54:14 +0200 Subject: [PATCH 94/97] feat(options): Source/Pit/Collapse/Rescore/Suggest + many search opts Options gains many new search-body fields: - _source (Source value object with includes/excludes) - track_total_hits, track_scores, explain - terminate_after, timeout - search_after, pit (point-in-time) - stored_fields, docvalue_fields, fields, script_fields - runtime_mappings, seq_no_primary_term - indices_boost - profile, stats, ext New top-level body features wired through ElasticQuery::toArray(): - Collapse (field collapsing) with InnerHits support - Rescore (multiple, secondary query over windowSize hits) - Suggest with typed suggesters: - TermSuggester (token suggestions) - PhraseSuggester (phrase suggestions) - CompletionSuggester (completion suggestions) - SuggesterInterface for extensibility Integration tests cover field collapsing, source filtering, rescore, and three suggesters against real ES indices. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ElasticQuery.php | 27 ++++ src/Options.php | 142 +++++++++++++++++- src/Options/Collapse.php | 53 +++++++ src/Options/Pit.php | 38 +++++ src/Options/Rescore.php | 58 +++++++ src/Options/Source.php | 53 +++++++ src/Options/Suggest/CompletionSuggester.php | 65 ++++++++ src/Options/Suggest/PhraseSuggester.php | 60 ++++++++ src/Options/Suggest/SuggesterInterface.php | 11 ++ src/Options/Suggest/TermSuggester.php | 54 +++++++ .../ElasticQuery/Options/Collapse.phpt | 61 ++++++++ .../ElasticQuery/Options/Rescore.phpt | 59 ++++++++ .../ElasticQuery/Options/Source.phpt | 54 +++++++ .../ElasticQuery/Options/Suggest.phpt | 117 +++++++++++++++ 14 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 src/Options/Collapse.php create mode 100644 src/Options/Pit.php create mode 100644 src/Options/Rescore.php create mode 100644 src/Options/Source.php create mode 100644 src/Options/Suggest/CompletionSuggester.php create mode 100644 src/Options/Suggest/PhraseSuggester.php create mode 100644 src/Options/Suggest/SuggesterInterface.php create mode 100644 src/Options/Suggest/TermSuggester.php create mode 100644 tests/SpameriTests/ElasticQuery/Options/Collapse.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Options/Rescore.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Options/Source.phpt create mode 100644 tests/SpameriTests/ElasticQuery/Options/Suggest.phpt diff --git a/src/ElasticQuery.php b/src/ElasticQuery.php index ea1c2b4..17f704f 100644 --- a/src/ElasticQuery.php +++ b/src/ElasticQuery.php @@ -174,6 +174,33 @@ public function toArray(): array $array['highlight'] = $this->highlight->toArray(); } + $collapse = $this->options->collapse(); + if ($collapse !== null) { + $array['collapse'] = $collapse->toArray(); + } + + $rescore = $this->options->rescore(); + if ($rescore !== null && $rescore !== []) { + $rescoreArray = []; + foreach ($rescore as $r) { + $rescoreArray[] = $r->toArray(); + } + $array['rescore'] = $rescoreArray; + } + + $suggesters = $this->options->suggesters(); + if ($suggesters !== null && $suggesters !== []) { + $suggest = []; + $text = $this->options->suggestText(); + if ($text !== null) { + $suggest['text'] = $text; + } + foreach ($suggesters as $suggester) { + $suggest[$suggester->key()] = $suggester->toArray(); + } + $array['suggest'] = $suggest; + } + return $array; } diff --git a/src/Options.php b/src/Options.php index d1f9345..1098497 100644 --- a/src/Options.php +++ b/src/Options.php @@ -11,6 +11,19 @@ class Options private \Spameri\ElasticQuery\Options\SortCollection $sort; + /** + * @param array|null $searchAfter + * @param array|null $storedFields + * @param array|null $docvalueFields + * @param array>|null $fields + * @param array>|null $scriptFields + * @param array|null $runtimeMappings + * @param array|null $suggesters + * @param array|null $rescore + * @param array>|null $indicesBoost + * @param array|null $stats + * @param array|null $ext + */ public function __construct( private int|null $size = null, private int|null $from = null, @@ -19,6 +32,28 @@ public function __construct( private bool $includeVersion = false, private string|null $scroll = null, private string|null $scrollId = null, + private \Spameri\ElasticQuery\Options\Source|null $source = null, + private bool|int|null $trackTotalHits = null, + private bool|null $trackScores = null, + private bool|null $explain = null, + private int|null $terminateAfter = null, + private string|null $timeout = null, + private array|null $searchAfter = null, + private \Spameri\ElasticQuery\Options\Pit|null $pit = null, + private array|null $storedFields = null, + private array|null $docvalueFields = null, + private array|null $fields = null, + private array|null $scriptFields = null, + private array|null $runtimeMappings = null, + private bool|null $seqNoPrimaryTerm = null, + private array|null $indicesBoost = null, + private \Spameri\ElasticQuery\Options\Collapse|null $collapse = null, + private array|null $rescore = null, + private array|null $suggesters = null, + private string|null $suggestText = null, + private bool|null $profile = null, + private array|null $stats = null, + private array|null $ext = null, ) { $this->sort = $sort ?: new \Spameri\ElasticQuery\Options\SortCollection(); @@ -71,6 +106,39 @@ public function scrollInitialized( } + public function collapse(): \Spameri\ElasticQuery\Options\Collapse|null + { + return $this->collapse; + } + + + /** + * @return array|null + */ + public function rescore(): array|null + { + return $this->rescore; + } + + + /** + * @return array|null + */ + public function suggesters(): array|null + { + return $this->suggesters; + } + + + public function suggestText(): string|null + { + return $this->suggestText; + } + + + /** + * @return array + */ public function toArray(): array { $array = []; @@ -92,7 +160,7 @@ public function toArray(): array $array['sort'][] = $item->toArray(); } - if ($this->minScore) { + if ($this->minScore !== null) { $array['min_score'] = $this->minScore; } @@ -105,6 +173,78 @@ public function toArray(): array $array['scroll'] = $this->scroll; } + if ($this->source !== null) { + $array['_source'] = $this->source->value(); + } + + if ($this->trackTotalHits !== null) { + $array['track_total_hits'] = $this->trackTotalHits; + } + + if ($this->trackScores !== null) { + $array['track_scores'] = $this->trackScores; + } + + if ($this->explain !== null) { + $array['explain'] = $this->explain; + } + + if ($this->terminateAfter !== null) { + $array['terminate_after'] = $this->terminateAfter; + } + + if ($this->timeout !== null) { + $array['timeout'] = $this->timeout; + } + + if ($this->searchAfter !== null) { + $array['search_after'] = $this->searchAfter; + } + + if ($this->pit !== null) { + $array['pit'] = $this->pit->toArray(); + } + + if ($this->storedFields !== null) { + $array['stored_fields'] = $this->storedFields; + } + + if ($this->docvalueFields !== null) { + $array['docvalue_fields'] = $this->docvalueFields; + } + + if ($this->fields !== null) { + $array['fields'] = $this->fields; + } + + if ($this->scriptFields !== null) { + $array['script_fields'] = $this->scriptFields; + } + + if ($this->runtimeMappings !== null) { + $array['runtime_mappings'] = $this->runtimeMappings; + } + + if ($this->seqNoPrimaryTerm !== null) { + $array['seq_no_primary_term'] = $this->seqNoPrimaryTerm; + } + + if ($this->indicesBoost !== null) { + $array['indices_boost'] = $this->indicesBoost; + } + + if ($this->profile !== null) { + $array['profile'] = $this->profile; + } + + if ($this->stats !== null) { + $array['stats'] = $this->stats; + } + + if ($this->ext !== null) { + $array['ext'] = $this->ext; + } + return $array; } diff --git a/src/Options/Collapse.php b/src/Options/Collapse.php new file mode 100644 index 0000000..4c8824e --- /dev/null +++ b/src/Options/Collapse.php @@ -0,0 +1,53 @@ +|\Spameri\ElasticQuery\Query\InnerHits|null $innerHits + */ + public function __construct( + private string $field, + private array|\Spameri\ElasticQuery\Query\InnerHits|null $innerHits = null, + private int|null $maxConcurrentGroupSearches = null, + ) + { + } + + + /** + * @return array + */ + public function toArray(): array + { + $array = ['field' => $this->field]; + + if ($this->innerHits !== null) { + if (\is_array($this->innerHits)) { + $array['inner_hits'] = []; + foreach ($this->innerHits as $ih) { + $array['inner_hits'][] = $ih->toArray(); + } + } else { + $array['inner_hits'] = $this->innerHits->toArray(); + } + } + + if ($this->maxConcurrentGroupSearches !== null) { + $array['max_concurrent_group_searches'] = $this->maxConcurrentGroupSearches; + } + + return $array; + } + +} diff --git a/src/Options/Pit.php b/src/Options/Pit.php new file mode 100644 index 0000000..dab0497 --- /dev/null +++ b/src/Options/Pit.php @@ -0,0 +1,38 @@ + + */ + public function toArray(): array + { + $array = ['id' => $this->id]; + + if ($this->keepAlive !== null) { + $array['keep_alive'] = $this->keepAlive; + } + + return $array; + } + +} diff --git a/src/Options/Rescore.php b/src/Options/Rescore.php new file mode 100644 index 0000000..b8f8529 --- /dev/null +++ b/src/Options/Rescore.php @@ -0,0 +1,58 @@ + + */ + public function toArray(): array + { + $rescoreQuery = ['rescore_query' => $this->query->toArray()]; + + if ($this->queryWeight !== null) { + $rescoreQuery['query_weight'] = $this->queryWeight; + } + + if ($this->rescoreQueryWeight !== null) { + $rescoreQuery['rescore_query_weight'] = $this->rescoreQueryWeight; + } + + if ($this->scoreMode !== null) { + $rescoreQuery['score_mode'] = $this->scoreMode; + } + + return [ + 'window_size' => $this->windowSize, + 'query' => $rescoreQuery, + ]; + } + +} diff --git a/src/Options/Source.php b/src/Options/Source.php new file mode 100644 index 0000000..2af8d29 --- /dev/null +++ b/src/Options/Source.php @@ -0,0 +1,53 @@ +|null $includes + * @param array|null $excludes + */ + public function __construct( + private bool|null $enabled = null, + private array|null $includes = null, + private array|null $excludes = null, + ) + { + } + + + /** + * @return bool|array + */ + public function value(): bool|array + { + if ($this->enabled === false) { + return false; + } + + if ($this->includes === null && $this->excludes === null) { + return true; + } + + $array = []; + if ($this->includes !== null) { + $array['includes'] = $this->includes; + } + if ($this->excludes !== null) { + $array['excludes'] = $this->excludes; + } + + return $array; + } + +} diff --git a/src/Options/Suggest/CompletionSuggester.php b/src/Options/Suggest/CompletionSuggester.php new file mode 100644 index 0000000..a9ce53a --- /dev/null +++ b/src/Options/Suggest/CompletionSuggester.php @@ -0,0 +1,65 @@ +|null $fuzzy + * @param array|null $regex + * @param array|null $contexts + */ + public function __construct( + private string $name, + private string $prefix, + private string $field, + private int|null $size = null, + private bool|null $skipDuplicates = null, + private array|null $fuzzy = null, + private array|null $regex = null, + private array|null $contexts = null, + ) + { + } + + + public function key(): string + { + return $this->name; + } + + + /** + * @return array + */ + public function toArray(): array + { + $body = ['field' => $this->field]; + + if ($this->size !== null) { + $body['size'] = $this->size; + } + if ($this->skipDuplicates !== null) { + $body['skip_duplicates'] = $this->skipDuplicates; + } + if ($this->fuzzy !== null) { + $body['fuzzy'] = $this->fuzzy; + } + if ($this->regex !== null) { + $body['regex'] = $this->regex; + } + if ($this->contexts !== null) { + $body['contexts'] = $this->contexts; + } + + return [ + 'prefix' => $this->prefix, + 'completion' => $body, + ]; + } + +} diff --git a/src/Options/Suggest/PhraseSuggester.php b/src/Options/Suggest/PhraseSuggester.php new file mode 100644 index 0000000..9ac4e26 --- /dev/null +++ b/src/Options/Suggest/PhraseSuggester.php @@ -0,0 +1,60 @@ +name; + } + + + /** + * @return array + */ + public function toArray(): array + { + $body = ['field' => $this->field]; + + if ($this->size !== null) { + $body['size'] = $this->size; + } + if ($this->gramSize !== null) { + $body['gram_size'] = $this->gramSize; + } + if ($this->confidence !== null) { + $body['confidence'] = $this->confidence; + } + if ($this->maxErrors !== null) { + $body['max_errors'] = $this->maxErrors; + } + if ($this->separator !== null) { + $body['separator'] = $this->separator; + } + + return [ + 'text' => $this->text, + 'phrase' => $body, + ]; + } + +} diff --git a/src/Options/Suggest/SuggesterInterface.php b/src/Options/Suggest/SuggesterInterface.php new file mode 100644 index 0000000..57ed994 --- /dev/null +++ b/src/Options/Suggest/SuggesterInterface.php @@ -0,0 +1,11 @@ +name; + } + + + /** + * @return array + */ + public function toArray(): array + { + $body = ['field' => $this->field]; + + if ($this->size !== null) { + $body['size'] = $this->size; + } + + if ($this->sort !== null) { + $body['sort'] = $this->sort; + } + + if ($this->suggestMode !== null) { + $body['suggest_mode'] = $this->suggestMode; + } + + return [ + 'text' => $this->text, + 'term' => $body, + ]; + } + +} diff --git a/tests/SpameriTests/ElasticQuery/Options/Collapse.phpt b/tests/SpameriTests/ElasticQuery/Options/Collapse.phpt new file mode 100644 index 0000000..b7dda52 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Options/Collapse.phpt @@ -0,0 +1,61 @@ + [ + 'properties' => [ + 'user_id' => ['type' => 'keyword'], + 'created' => ['type' => 'date'], + ], + ], + ]; + } + + + public function testToArray(): void + { + $collapse = new \Spameri\ElasticQuery\Options\Collapse( + field: 'user_id', + innerHits: new \Spameri\ElasticQuery\Query\InnerHits(name: 'recent', size: 5), + maxConcurrentGroupSearches: 4, + ); + + $array = $collapse->toArray(); + + \Tester\Assert::same('user_id', $array['field']); + \Tester\Assert::same('recent', $array['inner_hits']['name']); + \Tester\Assert::same(4, $array['max_concurrent_group_searches']); + } + + + public function testCreate(): void + { + $this->indexDocument(['user_id' => 'a', 'created' => '2024-01-01']); + $this->indexDocument(['user_id' => 'a', 'created' => '2024-06-01']); + $this->indexDocument(['user_id' => 'b', 'created' => '2024-03-01']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery( + options: new \Spameri\ElasticQuery\Options( + collapse: new \Spameri\ElasticQuery\Options\Collapse(field: 'user_id'), + ), + ); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(3, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Collapse())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Options/Rescore.phpt b/tests/SpameriTests/ElasticQuery/Options/Rescore.phpt new file mode 100644 index 0000000..23684ec --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Options/Rescore.phpt @@ -0,0 +1,59 @@ + ['properties' => ['title' => ['type' => 'text']]]]; + } + + + public function testToArray(): void + { + $rescore = new \Spameri\ElasticQuery\Options\Rescore( + query: new \Spameri\ElasticQuery\Query\ElasticMatch('title', 'foo'), + windowSize: 50, + queryWeight: 0.7, + rescoreQueryWeight: 1.2, + scoreMode: \Spameri\ElasticQuery\Options\Rescore::SCORE_MODE_TOTAL, + ); + + $array = $rescore->toArray(); + + \Tester\Assert::same(50, $array['window_size']); + \Tester\Assert::same(0.7, $array['query']['query_weight']); + \Tester\Assert::same('total', $array['query']['score_mode']); + } + + + public function testCreate(): void + { + $this->indexDocument(['title' => 'hello world']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery( + options: new \Spameri\ElasticQuery\Options( + rescore: [ + new \Spameri\ElasticQuery\Options\Rescore( + query: new \Spameri\ElasticQuery\Query\ElasticMatch('title', 'world'), + windowSize: 10, + ), + ], + ), + ); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\ElasticMatch('title', 'hello')); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Rescore())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Options/Source.phpt b/tests/SpameriTests/ElasticQuery/Options/Source.phpt new file mode 100644 index 0000000..755c946 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Options/Source.phpt @@ -0,0 +1,54 @@ +value()); + } + + + public function testValueIncludesExcludes(): void + { + $source = new \Spameri\ElasticQuery\Options\Source( + includes: ['title', 'body'], + excludes: ['password'], + ); + + $value = $source->value(); + \Tester\Assert::same(['title', 'body'], $value['includes']); + \Tester\Assert::same(['password'], $value['excludes']); + } + + + public function testCreate(): void + { + $this->indexDocument(['title' => 'hello', 'secret' => 'shh']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery( + options: new \Spameri\ElasticQuery\Options( + source: new \Spameri\ElasticQuery\Options\Source( + includes: ['title'], + excludes: ['secret'], + ), + ), + ); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Source())->run(); diff --git a/tests/SpameriTests/ElasticQuery/Options/Suggest.phpt b/tests/SpameriTests/ElasticQuery/Options/Suggest.phpt new file mode 100644 index 0000000..57ea3e2 --- /dev/null +++ b/tests/SpameriTests/ElasticQuery/Options/Suggest.phpt @@ -0,0 +1,117 @@ + [ + 'properties' => [ + 'title' => ['type' => 'text'], + 'suggest' => ['type' => 'completion'], + ], + ], + ]; + } + + + public function testTermSuggesterToArray(): void + { + $suggester = new \Spameri\ElasticQuery\Options\Suggest\TermSuggester( + name: 'title_suggest', + text: 'tring', + field: 'title', + size: 3, + ); + + $array = $suggester->toArray(); + + \Tester\Assert::same('tring', $array['text']); + \Tester\Assert::same('title', $array['term']['field']); + \Tester\Assert::same(3, $array['term']['size']); + } + + + public function testPhraseSuggesterToArray(): void + { + $suggester = new \Spameri\ElasticQuery\Options\Suggest\PhraseSuggester( + name: 'p', + text: 'noble prize', + field: 'title', + size: 5, + confidence: 0.9, + ); + + \Tester\Assert::same('phrase', \array_keys($suggester->toArray())[1]); + \Tester\Assert::same(0.9, $suggester->toArray()['phrase']['confidence']); + } + + + public function testCompletionSuggesterToArray(): void + { + $suggester = new \Spameri\ElasticQuery\Options\Suggest\CompletionSuggester( + name: 'c', + prefix: 'app', + field: 'suggest', + skipDuplicates: true, + ); + + \Tester\Assert::same('app', $suggester->toArray()['prefix']); + \Tester\Assert::true($suggester->toArray()['completion']['skip_duplicates']); + } + + + public function testCreateTermSuggester(): void + { + $this->indexDocument(['title' => 'string theory']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery( + options: new \Spameri\ElasticQuery\Options( + suggesters: [ + new \Spameri\ElasticQuery\Options\Suggest\TermSuggester( + name: 'my_suggest', + text: 'tring', + field: 'title', + ), + ], + ), + ); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(1, $this->search($elasticQuery)->stats()->total()); + } + + + public function testCreateCompletionSuggester(): void + { + $this->indexDocument(['title' => 'apple', 'suggest' => 'apple']); + $this->indexDocument(['title' => 'application', 'suggest' => 'application']); + + $elasticQuery = new \Spameri\ElasticQuery\ElasticQuery( + options: new \Spameri\ElasticQuery\Options( + suggesters: [ + new \Spameri\ElasticQuery\Options\Suggest\CompletionSuggester( + name: 'my_complete', + prefix: 'app', + field: 'suggest', + ), + ], + ), + ); + $elasticQuery->addMustQuery(new \Spameri\ElasticQuery\Query\MatchAll()); + + \Tester\Assert::same(2, $this->search($elasticQuery)->stats()->total()); + } + +} + +(new Suggest())->run(); From df4e324173b5839812d83e948308885655a6ba0c Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 17:57:38 +0200 Subject: [PATCH 95/97] feat(filter): expand FilterCollection to full bool body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter container previously only exposed must(), so filter contexts could not express must_not / should / filter clauses without dropping into raw arrays. FilterCollection now mirrors QueryCollection: must(), should(), mustNot(), filter() — each returns the appropriate typed collection. The bool body emits all four arms when populated. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Filter/FilterCollection.php | 66 ++++++++++++++++--- .../ElasticQuery/Filter/FilterCollection.phpt | 51 ++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/Filter/FilterCollection.php b/src/Filter/FilterCollection.php index 4e0d460..06383b8 100644 --- a/src/Filter/FilterCollection.php +++ b/src/Filter/FilterCollection.php @@ -8,13 +8,26 @@ class FilterCollection implements FilterInterface { + private \Spameri\ElasticQuery\Query\MustCollection $mustCollection; + + private \Spameri\ElasticQuery\Query\ShouldCollection $shouldCollection; + + private \Spameri\ElasticQuery\Query\MustNotCollection $mustNotCollection; + + private \Spameri\ElasticQuery\Query\MustCollection $filterCollection; + + public function __construct( - private \Spameri\ElasticQuery\Query\MustCollection|null $mustCollection = null, + \Spameri\ElasticQuery\Query\MustCollection|null $mustCollection = null, + \Spameri\ElasticQuery\Query\ShouldCollection|null $shouldCollection = null, + \Spameri\ElasticQuery\Query\MustNotCollection|null $mustNotCollection = null, + \Spameri\ElasticQuery\Query\MustCollection|null $filterCollection = null, ) { - if ($this->mustCollection === null) { - $this->mustCollection = new \Spameri\ElasticQuery\Query\MustCollection(); - } + $this->mustCollection = $mustCollection ?? new \Spameri\ElasticQuery\Query\MustCollection(); + $this->shouldCollection = $shouldCollection ?? new \Spameri\ElasticQuery\Query\ShouldCollection(); + $this->mustNotCollection = $mustNotCollection ?? new \Spameri\ElasticQuery\Query\MustNotCollection(); + $this->filterCollection = $filterCollection ?? new \Spameri\ElasticQuery\Query\MustCollection(); } @@ -24,21 +37,58 @@ public function must(): \Spameri\ElasticQuery\Query\MustCollection } + public function should(): \Spameri\ElasticQuery\Query\ShouldCollection + { + return $this->shouldCollection; + } + + + public function mustNot(): \Spameri\ElasticQuery\Query\MustNotCollection + { + return $this->mustNotCollection; + } + + + public function filter(): \Spameri\ElasticQuery\Query\MustCollection + { + return $this->filterCollection; + } + + public function key(): string { return ''; } + /** + * @return array> + */ public function toArray(): array { - $array = []; - /** @var \Spameri\ElasticQuery\Query\LeafQueryInterface $item */ + $bool = []; + foreach ($this->mustCollection as $item) { - $array['bool']['must'][] = $item->toArray(); + $bool['must'][] = $item->toArray(); + } + + foreach ($this->shouldCollection as $item) { + $bool['should'][] = $item->toArray(); + } + + foreach ($this->mustNotCollection as $item) { + $bool['must_not'][] = $item->toArray(); + } + + foreach ($this->filterCollection as $item) { + $bool['filter'][] = $item->toArray(); + } + + if ($bool === []) { + return []; } - return $array; + return ['bool' => $bool]; } } diff --git a/tests/SpameriTests/ElasticQuery/Filter/FilterCollection.phpt b/tests/SpameriTests/ElasticQuery/Filter/FilterCollection.phpt index 867e950..06525fe 100644 --- a/tests/SpameriTests/ElasticQuery/Filter/FilterCollection.phpt +++ b/tests/SpameriTests/ElasticQuery/Filter/FilterCollection.phpt @@ -195,6 +195,57 @@ class FilterCollection extends \Tester\TestCase } + public function testShouldArm(): void + { + $filter = new \Spameri\ElasticQuery\Filter\FilterCollection(); + $filter->should()->add(new \Spameri\ElasticQuery\Query\Term('status', 'a')); + $filter->should()->add(new \Spameri\ElasticQuery\Query\Term('status', 'b')); + + $array = $filter->toArray(); + + \Tester\Assert::count(2, $array['bool']['should']); + } + + + public function testMustNotArm(): void + { + $filter = new \Spameri\ElasticQuery\Filter\FilterCollection(); + $filter->mustNot()->add(new \Spameri\ElasticQuery\Query\Term('deleted', true)); + + $array = $filter->toArray(); + + \Tester\Assert::count(1, $array['bool']['must_not']); + } + + + public function testFilterArm(): void + { + $filter = new \Spameri\ElasticQuery\Filter\FilterCollection(); + $filter->filter()->add(new \Spameri\ElasticQuery\Query\Term('region', 'eu')); + + $array = $filter->toArray(); + + \Tester\Assert::count(1, $array['bool']['filter']); + } + + + public function testAllArmsCombined(): void + { + $filter = new \Spameri\ElasticQuery\Filter\FilterCollection(); + $filter->must()->add(new \Spameri\ElasticQuery\Query\Term('m', 'x')); + $filter->should()->add(new \Spameri\ElasticQuery\Query\Term('s', 'y')); + $filter->mustNot()->add(new \Spameri\ElasticQuery\Query\Term('mn', 'z')); + $filter->filter()->add(new \Spameri\ElasticQuery\Query\Term('f', 'w')); + + $array = $filter->toArray(); + + \Tester\Assert::count(1, $array['bool']['must']); + \Tester\Assert::count(1, $array['bool']['should']); + \Tester\Assert::count(1, $array['bool']['must_not']); + \Tester\Assert::count(1, $array['bool']['filter']); + } + + public function testComplexFilterScenario(): void { $filter = new \Spameri\ElasticQuery\Filter\FilterCollection(); From 5c3bff2e468a9ad0ddd9880e26fe2987e9346b1c Mon Sep 17 00:00:00 2001 From: Spamer Date: Wed, 20 May 2026 18:00:52 +0200 Subject: [PATCH 96/97] docs: CHANGELOG + README + Query doc updates for v2 - New CHANGELOG.md documenting the full v2 surface: test infrastructure, the 4 bug fixes, ~150 new constructor arguments across existing classes, ~25 new query/aggregation/score-function/ sort/option types, and BC-affecting rewrites (GeoDistance, Nested, WeightedAvg, TopHits, Filter agg, IpRange, Composite, Highlight, FilterCollection). - README features list rewritten to reflect v2 coverage. - doc/02-query-objects.md updated where breaking changes landed: GeoDistance now takes distance + validation_method + ignore_unmapped + boost; Nested takes score_mode/ignore_unmapped/inner_hits; Terms accepts TermsLookup. New sections for Knn, SparseVector, Semantic, TextExpansion, RuleQuery, WeightedTokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 173 ++++++++++++++++++++++++++++++++++++++++ README.md | 17 ++-- doc/02-query-objects.md | 113 ++++++++++++++++++++++++-- 3 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6f3b223 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,173 @@ +# Changelog + +## v2 — full DSL coverage + +This branch brings every documented Elasticsearch DSL feature under typed PHP objects, fixes two queries that produced invalid DSL, and round-trips every feature against a real ES container via the new `AbstractElasticTestCase`. + +### Test infrastructure + +- New `tests/SpameriTests/ElasticQuery/AbstractElasticTestCase` base class with `createIndex($mapping)`, `indexDocument($body, $id, $refresh)`, `search($elasticQuery)`, `deleteIndex()`, and a generic `request($method, $path, $body)`. Tests that extend it shrink from ~70 lines of curl boilerplate to ~15. + +### Bug fixes (BC-breaking) + +| File | Previous | Fixed | +| --- | --- | --- | +| `Query/GeoDistance` | Emitted `{pin: {location: ...}}` (invalid DSL) and lacked the required `distance` argument. | Emits proper `geo_distance` envelope. Constructor now takes `distance` (required), plus `distance_type`, `validation_method`, `ignore_unmapped`, `boost`. | +| `Query/Nested` | Wrapped inner query in `[$queryArray]` (extra list level — rejected by ES). | Inner query is now an object. Added `score_mode`, `ignore_unmapped`, `inner_hits`. | +| `Query/PhrasePrefix` | `int $boost = 1` (inconsistent type). | `float $boost = 1.0`. | +| `Options/GeoDistanceSort` | `ignore_unmapped` hard-coded to `true`. | Constructor arg `bool $ignoreUnmapped = true`. | + +### New query types + +- **Knn** — vector similarity (field, queryVector, k, numCandidates, similarity, filter, boost). +- **SparseVector** — ELSER-style sparse vector query (inference_id+query or queryVector tokens). +- **TextExpansion** — legacy ELSER form (model_id, model_text). +- **Semantic** — queries a `semantic_text` field. +- **RuleQuery** — Search Application query rules over an organic query. +- **WeightedTokens** — token weights against a sparse_vector field. + +### Existing queries — new constructor arguments + +| Query | New args | +| --- | --- | +| `ElasticMatch` | `zero_terms_query`, `auto_generate_synonyms_phrase_query`, `lenient`, `prefix_length`, `max_expansions`, `fuzzy_transpositions`, `fuzzy_rewrite` | +| `MultiMatch` | `tie_breaker`, `slop`, `prefix_length`, `max_expansions`, `lenient`, `zero_terms_query`, `auto_generate_synonyms_phrase_query`, `fuzzy_transpositions`, `fuzzy_rewrite` | +| `MatchPhrase` | `zero_terms_query` | +| `PhrasePrefix` | `analyzer`, `max_expansions`, `zero_terms_query` | +| `MatchBoolPrefix` | `fuzziness`, `prefix_length`, `max_expansions`, `fuzzy_transpositions`, `fuzzy_rewrite` | +| `QueryString` | `analyze_wildcard`, `auto_generate_synonyms_phrase_query`, `enable_position_increments`, `fuzziness`, `fuzzy_max_expansions`, `fuzzy_prefix_length`, `fuzzy_transpositions`, `lenient`, `max_determinized_states`, `minimum_should_match`, `quote_analyzer`, `phrase_slop`, `quote_field_suffix`, `rewrite`, `time_zone`, `type`, `tie_breaker` | +| `SimpleQueryString` | `analyze_wildcard`, `auto_generate_synonyms_phrase_query`, `fuzzy_max_expansions`, `fuzzy_prefix_length`, `fuzzy_transpositions`, `lenient`, `minimum_should_match`, `quote_field_suffix` | +| `CombinedFields` | `auto_generate_synonyms_phrase_query` | +| `Term` | `case_insensitive` | +| `Terms` | accepts `TermsLookup` for cross-document terms resolution | +| `Range` | `gt`, `lt`, `format`, `relation` (new `Range\Relation` constants), `time_zone` | +| `Exists` | `boost` | +| `WildCard` | `case_insensitive`, `rewrite` | +| `Prefix` | `rewrite` | +| `Fuzzy` | `transpositions`, `rewrite` | +| `Regexp` | `rewrite` | +| `TermSet` | `boost` | +| `HasChild` | `inner_hits` | +| `HasParent` | `inner_hits` | +| `Nested` | `score_mode`, `ignore_unmapped`, `inner_hits` | +| `ParentId` | `boost` | +| `GeoBoundingBox` | `validation_method`, `ignore_unmapped`, `boost` | +| `GeoShape` | `indexed_shape` (new `IndexedShape` sub-object), `boost` | +| `Shape` | `indexed_shape`, `boost` | +| `MoreLikeThis` | `boost_terms`, `include`, `min_doc_freq`, `max_doc_freq`, `min_word_length`, `max_word_length`, `stop_words`, `analyzer`, `boost`, `fail_on_unsupported_field` | +| `Percolate` | `documents` (multi-doc), `name`, `routing`, `preference`, `version` | + +### New sub-objects + +- `Query/TermsLookup` — `index`, `id`, `path`, `routing`. +- `Query/Range/Relation` — constants: `INTERSECTS`, `CONTAINS`, `WITHIN`. +- `Query/InnerHits` — `name`, `from`, `size`, `sort`, `_source`, `highlight`, `explain`, `script_fields`, `docvalue_fields`, `version`, `seq_no_primary_term`, `stored_fields`, `track_scores`. +- `Query/IndexedShape` — `id`, `index`, `path`, `routing`. +- `Script` (top-level) — reusable script value object (`source`, `lang`, `params`). + +### Aggregations — new types + +Bucket: `Filters` (named filters), `AutoDateHistogram`, `VariableWidthHistogram`, `CategorizeText` *(platinum license)*, `FrequentItemSets` *(platinum license)*, `IpPrefix`, `TimeSeries`. + +Metric: `TopMetrics`, `GeoLine` *(gold license)*, `TTest`, `Rate`, `MatrixStats`. + +Pipeline/sampler/ML: `RandomSampler`, `CumulativeCardinality`, `ExtendedStatsBucket`, `Inference`. + +### Aggregations — new constructor arguments + +| Agg | New args | +| --- | --- | +| `Min`/`Max`/`Avg`/`Sum`/`ValueCount`/`Stats` | `missing`, `script`, `format` | +| `ExtendedStats` | `missing`, `script`, `format` (kept `sigma`) | +| `Cardinality` | `script`, `missing`, `rehash` | +| `MedianAbsoluteDeviation`/`StringStats` | `missing`, `script` | +| `BoxPlot` | `missing`, `script`, `execution_hint` | +| `Percentiles` | `tdigest`, `hdr`, `missing`, `script` | +| `PercentileRanks` | `hdr`, `missing`, `script` | +| `WeightedAvg` | **rewritten** — takes typed `WeightedAvgValue` for value/weight (each with `field`/`script`/`missing`), plus `format` | +| `TopHits` | **rewritten** — `from`, `sort`, `_source`, `highlight`, `explain`, `script_fields`, `docvalue_fields`, `version`, `seq_no_primary_term`, `stored_fields`, `track_scores` | +| `Term` | `min_doc_count`, `shard_size`, `shard_min_doc_count`, `show_term_doc_count_error`, `script`, `collect_mode`, `execution_hint`, `value_type`, `format`; `include`/`exclude` accept arrays | +| `MultiTerms` | `order`, `min_doc_count`, `shard_size`, `shard_min_doc_count`, `collect_mode`, `format` | +| `RareTerms` | `include`, `exclude`, `missing` | +| `SignificantTerms` | `shard_size`, `shard_min_doc_count`, `execution_hint`, `background_filter`, `heuristic` (with `HEURISTIC_*` constants) | +| `SignificantText` | `shard_size`, `shard_min_doc_count`, `min_doc_count`, `background_filter`, `source_fields` | +| `Range` | `script`, `missing`, `format` | +| `DateRange` | `script`, `missing` | +| `Histogram` | `min_doc_count`, `extended_bounds` (new `Histogram\Bounds`), `hard_bounds`, `offset`, `order`, `script`, `missing`, `keyed`, `format` | +| `DateHistogram` | `extended_bounds`, `hard_bounds`, `keyed`, `order`, `script`, `missing` | +| `IpRange` | **rewritten** — new `IpRange\IpRangeValue` with `mask` (CIDR) support | +| `Filter` | **rewritten** — accepts any `LeafQueryInterface` directly | +| `Composite` | typed sources: `Composite\TermsSource`, `Composite\HistogramSource`, `Composite\DateHistogramSource`, `Composite\GeotileGridSource`, each with `order`/`missing_bucket` | +| `AdjacencyMatrix` | `separator`, accepts `LeafQueryInterface` for filters | +| `GeoDistance` (agg) | `keyed`, `script`, `missing` | +| `GeoHashGrid`/`GeoTileGrid` | `bounds` | +| `DiversifiedSampler` | `execution_hint`, `script` | +| `Missing` | `script` | + +### Score functions + +- New `FunctionScore/ScoreFunction/Decay/Gauss`, `Linear`, `Exp` with shared `AbstractDecay` parent (`field`, `origin`, `scale`, `offset`, `decay`, `multi_value_mode`). +- New `FunctionScore/ScoreFunction/ScriptScore` (function variant — distinct from the `Query/ScriptScore` leaf). +- `FunctionScore` gained `boost`, `boost_mode` (with `BOOST_MODE_*` constants), `max_boost`, `min_score`. + +### Sort + +- `Sort` gains `mode`, `nested` (new `NestedSort`), `numeric_type`, `unmapped_type`, `format`. +- New `Options/ScriptSort` — script-based sort. +- New `Options/NestedSort` — path/filter/max_children for nested sorting (recursive). + +### Highlight — rewritten + +- `Highlight/HighlightField` — per-field config (type, number_of_fragments, fragment_size, all boundary_*, encoder, force_source, fragmenter, highlight_query, matched_fields, no_match_size, order, phrase_limit, require_field_match, tags_schema, pre_tags, post_tags). +- `Highlight/HighlightFieldCollection` — typed collection. +- `Highlight` accepts either `HighlightFieldCollection` or simple `array` of field names (BC). Adds all global options. + +### Options — many new fields + +| Field | Type | +| --- | --- | +| `_source` | new `Options\Source` (includes/excludes, or `false`) | +| `track_total_hits` | `bool\|int` | +| `track_scores` | `bool` | +| `explain` | `bool` | +| `terminate_after` | `int` | +| `timeout` | `string` | +| `search_after` | `array` | +| `pit` | new `Options\Pit` | +| `stored_fields` | `array` | +| `docvalue_fields` | `array` | +| `fields` | `array` | +| `script_fields` | `array` | +| `runtime_mappings` | `array` | +| `seq_no_primary_term` | `bool` | +| `indices_boost` | `array` | +| `collapse` | new `Options\Collapse` | +| `rescore` | `array` | +| `suggesters` | `array` | +| `profile` | `bool` | +| `stats` | `array` | +| `ext` | `array` | + +`ElasticQuery::toArray()` wires `collapse`, `rescore`, and `suggest` to the top-level request body. + +### Filter container — bool expansion + +`Filter/FilterCollection` previously exposed only `must()`. It now mirrors `Query/QueryCollection` with `must()`, `should()`, `mustNot()`, and `filter()` — the `bool` body emits all four arms. + +### Suggesters + +- `Options/Suggest/SuggesterInterface` +- `Options/Suggest/TermSuggester` +- `Options/Suggest/PhraseSuggester` +- `Options/Suggest/CompletionSuggester` + +### Response mapper + +- `ResultMapper` now handles named buckets (string keys, e.g. from `Filters` agg) and composite-key buckets (array keys). +- `Result/Aggregation/Bucket.from`/`to` accept `string` (e.g. for IP / date range buckets). + +### CI / tests + +- 218 tests, 3 skipped on basic license (geo_line, categorize_text). +- ES 9.2.2 container in CI; `make tests` passes end-to-end against it. +- The two pre-existing buggy tests for `GeoDistance` and `Nested` (which asserted invalid output) are now corrected and re-run as integration tests against ES. diff --git a/README.md b/README.md index 68e3466..a1868ab 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,16 @@ A PHP library that converts Elasticsearch query DSL into strongly-typed PHP obje ## Features -- **Type-safe queries** - Full-text, term-level, compound, geo, and nested queries -- **Aggregations** - Metric (min, max, avg) and bucket (terms, histogram, range, filter) aggregations -- **Response mapping** - Automatic mapping of Elasticsearch responses to typed objects -- **Index mapping** - Define index settings, analyzers, tokenizers, and filters -- **Function scoring** - Custom scoring with field value factors, weights, and random scores -- **Highlighting** - Search result highlighting support -- **Pagination & sorting** - Options for size, offset, scroll, and geo-distance sorting +- **Type-safe queries** — full-text, term-level, compound, geo, nested, joining, vector (knn / sparse_vector / semantic), span queries, and rule queries +- **Aggregations** — metric (min, max, avg, stats, weighted_avg, top_hits, top_metrics, t_test, geo_line, …), bucket (terms, histogram, date_histogram, range, filter, filters, composite with typed sources, ip_prefix, time_series, …), pipeline (cumulative_*, bucket_*, normalize, serial_diff, inference, …) +- **Function scoring** — field value factor, weight, random, decay (gauss / linear / exp), script_score; score_mode + boost_mode +- **Sort** — field, geo-distance, script-based, with nested sort (filter / max_children / recursive) +- **Highlight** — per-field config (type, fragment_size, boundary scanner, encoder, fragmenter, highlight_query, matched_fields, no_match_size, order, phrase_limit, …) +- **Search options** — `_source`, `track_total_hits`, `search_after`, `pit`, `collapse`, `rescore`, `suggest` (term / phrase / completion), `runtime_mappings`, `script_fields`, `docvalue_fields`, `stored_fields`, `terminate_after`, `timeout`, `profile`, `stats`, `ext` +- **Response mapping** — automatic mapping of Elasticsearch responses (including composite/named buckets, IP/date range buckets) to typed objects +- **Index mapping** — index settings, analyzers, tokenizers, filters + +See [CHANGELOG.md](CHANGELOG.md) for the full list of types and arguments added in v2. ## Requirements diff --git a/doc/02-query-objects.md b/doc/02-query-objects.md index d6f7daf..12b76f7 100644 --- a/doc/02-query-objects.md +++ b/doc/02-query-objects.md @@ -179,15 +179,27 @@ new \Spameri\ElasticQuery\Query\Term( ``` ##### Terms Query -Match any of multiple exact values. +Match any of multiple exact values, or fetch values from another document. - Class: `\Spameri\ElasticQuery\Query\Terms` - [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html) - [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Terms.php) ```php +// Inline values new \Spameri\ElasticQuery\Query\Terms( field: 'category', - values: ['books', 'movies', 'music'], + query: ['books', 'movies', 'music'], +); + +// terms_lookup — values pulled from another document +new \Spameri\ElasticQuery\Query\Terms( + field: 'user_id', + query: new \Spameri\ElasticQuery\Query\TermsLookup( + index: 'users', + id: '42', + path: 'friends', + routing: null, + ), ); ``` @@ -418,12 +430,98 @@ Query nested objects with their own scope. - [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Nested.php) ```php -$nested = new \Spameri\ElasticQuery\Query\Nested(path: 'comments'); +$nested = new \Spameri\ElasticQuery\Query\Nested( + path: 'comments', + scoreMode: \Spameri\ElasticQuery\Query\Nested::SCORE_MODE_AVG, // optional + ignoreUnmapped: false, // optional + innerHits: new \Spameri\ElasticQuery\Query\InnerHits( // optional + name: 'matched_comments', + size: 5, + ), +); $nested->getQuery()->must()->add( new \Spameri\ElasticQuery\Query\Term('comments.author', 'john') ); -$nested->getQuery()->must()->add( - new \Spameri\ElasticQuery\Query\Range('comments.date', gte: '2024-01-01') +``` + +##### Knn Query +k-nearest neighbour vector similarity search. +- Class: `\Spameri\ElasticQuery\Query\Knn` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-knn-query.html) +- [Implementation](https://github.com/Spameri/ElasticQuery/blob/master/src/Query/Knn.php) + +```php +new \Spameri\ElasticQuery\Query\Knn( + field: 'vector', + queryVector: [1.0, 2.0, 3.0], + k: 5, + numCandidates: 50, + similarity: 0.7, // optional + filter: new \Spameri\ElasticQuery\Query\Term('status', 'on'), // optional + boost: 1.0, +); +``` + +##### SparseVector Query +Sparse vector / ELSER-style query. +- Class: `\Spameri\ElasticQuery\Query\SparseVector` +- [Documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-sparse-vector-query.html) + +```php +// Via inference endpoint +new \Spameri\ElasticQuery\Query\SparseVector( + field: 'tokens', + inferenceId: '.elser_model_2', + query: 'big cat', +); + +// Via pre-computed tokens +new \Spameri\ElasticQuery\Query\SparseVector( + field: 'tokens', + queryVector: ['lion' => 0.5, 'tiger' => 0.7], +); +``` + +##### Semantic Query +Query a `semantic_text` field. +- Class: `\Spameri\ElasticQuery\Query\Semantic` + +```php +new \Spameri\ElasticQuery\Query\Semantic(field: 'inference_field', query: 'large cat'); +``` + +##### TextExpansion Query +Legacy ELSER (`model_id`/`model_text`). +- Class: `\Spameri\ElasticQuery\Query\TextExpansion` + +```php +new \Spameri\ElasticQuery\Query\TextExpansion( + field: 'tokens', + modelId: '.elser_model_2', + modelText: 'big cat', +); +``` + +##### RuleQuery +Apply Search Application query rules over an organic query. +- Class: `\Spameri\ElasticQuery\Query\RuleQuery` + +```php +new \Spameri\ElasticQuery\Query\RuleQuery( + organic: new \Spameri\ElasticQuery\Query\ElasticMatch('title', 'puggles'), + rulesetIds: ['my-ruleset'], + matchCriteria: ['query_string' => 'puggles'], +); +``` + +##### WeightedTokens Query +Token weights against a sparse_vector field. +- Class: `\Spameri\ElasticQuery\Query\WeightedTokens` + +```php +new \Spameri\ElasticQuery\Query\WeightedTokens( + field: 'tokens', + tokens: ['lion' => 0.5, 'tiger' => 0.7], ); ``` @@ -438,6 +536,11 @@ new \Spameri\ElasticQuery\Query\GeoDistance( field: 'location', lat: 40.7128, lon: -74.0060, + distance: '50km', + distanceType: 'arc', // optional: 'arc' | 'plane' + validationMethod: 'STRICT', // optional: 'STRICT' | 'COERCE' | 'IGNORE_MALFORMED' + ignoreUnmapped: false, // optional + boost: 1.0, ); ``` From fe3089f5fe48f650de8cc573eb25164df97abbec Mon Sep 17 00:00:00 2001 From: Spamer Date: Tue, 2 Jun 2026 16:44:09 +0200 Subject: [PATCH 97/97] fix(tests): drop deprecated curl_close() call curl_close() is a no-op since PHP 8.0 and is deprecated in PHP 8.5, so the Generic.PHP.DeprecatedFunctions sniff flags it via reflection and fails `make cs` on the 8.5 CI matrix. The CurlHandle is freed automatically when $ch goes out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php b/tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php index bef9166..56b6763 100644 --- a/tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php +++ b/tests/SpameriTests/ElasticQuery/AbstractElasticTestCase.php @@ -105,7 +105,6 @@ protected function request( } $response = \curl_exec($ch); - \curl_close($ch); if ($response === false) { return [];