Skip to content

Commit 958e306

Browse files
Spamerczclaude
andcommitted
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) <noreply@anthropic.com>
1 parent cad64aa commit 958e306

14 files changed

Lines changed: 851 additions & 1 deletion

File tree

src/ElasticQuery.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,33 @@ public function toArray(): array
174174
$array['highlight'] = $this->highlight->toArray();
175175
}
176176

177+
$collapse = $this->options->collapse();
178+
if ($collapse !== null) {
179+
$array['collapse'] = $collapse->toArray();
180+
}
181+
182+
$rescore = $this->options->rescore();
183+
if ($rescore !== null && $rescore !== []) {
184+
$rescoreArray = [];
185+
foreach ($rescore as $r) {
186+
$rescoreArray[] = $r->toArray();
187+
}
188+
$array['rescore'] = $rescoreArray;
189+
}
190+
191+
$suggesters = $this->options->suggesters();
192+
if ($suggesters !== null && $suggesters !== []) {
193+
$suggest = [];
194+
$text = $this->options->suggestText();
195+
if ($text !== null) {
196+
$suggest['text'] = $text;
197+
}
198+
foreach ($suggesters as $suggester) {
199+
$suggest[$suggester->key()] = $suggester->toArray();
200+
}
201+
$array['suggest'] = $suggest;
202+
}
203+
177204
return $array;
178205
}
179206

src/Options.php

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ class Options
1111
private \Spameri\ElasticQuery\Options\SortCollection $sort;
1212

1313

14+
/**
15+
* @param array<int|string, mixed>|null $searchAfter
16+
* @param array<int, string>|null $storedFields
17+
* @param array<int, string>|null $docvalueFields
18+
* @param array<int, array<string, mixed>>|null $fields
19+
* @param array<string, array<string, mixed>>|null $scriptFields
20+
* @param array<string, mixed>|null $runtimeMappings
21+
* @param array<int, \Spameri\ElasticQuery\Options\Suggest\SuggesterInterface>|null $suggesters
22+
* @param array<int, \Spameri\ElasticQuery\Options\Rescore>|null $rescore
23+
* @param array<int, array<string, float>>|null $indicesBoost
24+
* @param array<int, string>|null $stats
25+
* @param array<string, mixed>|null $ext
26+
*/
1427
public function __construct(
1528
private int|null $size = null,
1629
private int|null $from = null,
@@ -19,6 +32,28 @@ public function __construct(
1932
private bool $includeVersion = false,
2033
private string|null $scroll = null,
2134
private string|null $scrollId = null,
35+
private \Spameri\ElasticQuery\Options\Source|null $source = null,
36+
private bool|int|null $trackTotalHits = null,
37+
private bool|null $trackScores = null,
38+
private bool|null $explain = null,
39+
private int|null $terminateAfter = null,
40+
private string|null $timeout = null,
41+
private array|null $searchAfter = null,
42+
private \Spameri\ElasticQuery\Options\Pit|null $pit = null,
43+
private array|null $storedFields = null,
44+
private array|null $docvalueFields = null,
45+
private array|null $fields = null,
46+
private array|null $scriptFields = null,
47+
private array|null $runtimeMappings = null,
48+
private bool|null $seqNoPrimaryTerm = null,
49+
private array|null $indicesBoost = null,
50+
private \Spameri\ElasticQuery\Options\Collapse|null $collapse = null,
51+
private array|null $rescore = null,
52+
private array|null $suggesters = null,
53+
private string|null $suggestText = null,
54+
private bool|null $profile = null,
55+
private array|null $stats = null,
56+
private array|null $ext = null,
2257
)
2358
{
2459
$this->sort = $sort ?: new \Spameri\ElasticQuery\Options\SortCollection();
@@ -71,6 +106,39 @@ public function scrollInitialized(
71106
}
72107

73108

109+
public function collapse(): \Spameri\ElasticQuery\Options\Collapse|null
110+
{
111+
return $this->collapse;
112+
}
113+
114+
115+
/**
116+
* @return array<int, \Spameri\ElasticQuery\Options\Rescore>|null
117+
*/
118+
public function rescore(): array|null
119+
{
120+
return $this->rescore;
121+
}
122+
123+
124+
/**
125+
* @return array<int, \Spameri\ElasticQuery\Options\Suggest\SuggesterInterface>|null
126+
*/
127+
public function suggesters(): array|null
128+
{
129+
return $this->suggesters;
130+
}
131+
132+
133+
public function suggestText(): string|null
134+
{
135+
return $this->suggestText;
136+
}
137+
138+
139+
/**
140+
* @return array<string, mixed>
141+
*/
74142
public function toArray(): array
75143
{
76144
$array = [];
@@ -92,7 +160,7 @@ public function toArray(): array
92160
$array['sort'][] = $item->toArray();
93161
}
94162

95-
if ($this->minScore) {
163+
if ($this->minScore !== null) {
96164
$array['min_score'] = $this->minScore;
97165
}
98166

@@ -105,6 +173,78 @@ public function toArray(): array
105173
$array['scroll'] = $this->scroll;
106174
}
107175

176+
if ($this->source !== null) {
177+
$array['_source'] = $this->source->value();
178+
}
179+
180+
if ($this->trackTotalHits !== null) {
181+
$array['track_total_hits'] = $this->trackTotalHits;
182+
}
183+
184+
if ($this->trackScores !== null) {
185+
$array['track_scores'] = $this->trackScores;
186+
}
187+
188+
if ($this->explain !== null) {
189+
$array['explain'] = $this->explain;
190+
}
191+
192+
if ($this->terminateAfter !== null) {
193+
$array['terminate_after'] = $this->terminateAfter;
194+
}
195+
196+
if ($this->timeout !== null) {
197+
$array['timeout'] = $this->timeout;
198+
}
199+
200+
if ($this->searchAfter !== null) {
201+
$array['search_after'] = $this->searchAfter;
202+
}
203+
204+
if ($this->pit !== null) {
205+
$array['pit'] = $this->pit->toArray();
206+
}
207+
208+
if ($this->storedFields !== null) {
209+
$array['stored_fields'] = $this->storedFields;
210+
}
211+
212+
if ($this->docvalueFields !== null) {
213+
$array['docvalue_fields'] = $this->docvalueFields;
214+
}
215+
216+
if ($this->fields !== null) {
217+
$array['fields'] = $this->fields;
218+
}
219+
220+
if ($this->scriptFields !== null) {
221+
$array['script_fields'] = $this->scriptFields;
222+
}
223+
224+
if ($this->runtimeMappings !== null) {
225+
$array['runtime_mappings'] = $this->runtimeMappings;
226+
}
227+
228+
if ($this->seqNoPrimaryTerm !== null) {
229+
$array['seq_no_primary_term'] = $this->seqNoPrimaryTerm;
230+
}
231+
232+
if ($this->indicesBoost !== null) {
233+
$array['indices_boost'] = $this->indicesBoost;
234+
}
235+
236+
if ($this->profile !== null) {
237+
$array['profile'] = $this->profile;
238+
}
239+
240+
if ($this->stats !== null) {
241+
$array['stats'] = $this->stats;
242+
}
243+
244+
if ($this->ext !== null) {
245+
$array['ext'] = $this->ext;
246+
}
247+
108248
return $array;
109249
}
110250

src/Options/Collapse.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Spameri\ElasticQuery\Options;
6+
7+
8+
/**
9+
* Field collapsing — group hits by a field's value.
10+
*
11+
* @see https://www.elastic.co/guide/en/elasticsearch/reference/current/collapse-search-results.html
12+
*/
13+
class Collapse implements \Spameri\ElasticQuery\Entity\ArrayInterface
14+
{
15+
16+
/**
17+
* @param array<int, \Spameri\ElasticQuery\Query\InnerHits>|\Spameri\ElasticQuery\Query\InnerHits|null $innerHits
18+
*/
19+
public function __construct(
20+
private string $field,
21+
private array|\Spameri\ElasticQuery\Query\InnerHits|null $innerHits = null,
22+
private int|null $maxConcurrentGroupSearches = null,
23+
)
24+
{
25+
}
26+
27+
28+
/**
29+
* @return array<string, mixed>
30+
*/
31+
public function toArray(): array
32+
{
33+
$array = ['field' => $this->field];
34+
35+
if ($this->innerHits !== null) {
36+
if (\is_array($this->innerHits)) {
37+
$array['inner_hits'] = [];
38+
foreach ($this->innerHits as $ih) {
39+
$array['inner_hits'][] = $ih->toArray();
40+
}
41+
} else {
42+
$array['inner_hits'] = $this->innerHits->toArray();
43+
}
44+
}
45+
46+
if ($this->maxConcurrentGroupSearches !== null) {
47+
$array['max_concurrent_group_searches'] = $this->maxConcurrentGroupSearches;
48+
}
49+
50+
return $array;
51+
}
52+
53+
}

src/Options/Pit.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Spameri\ElasticQuery\Options;
6+
7+
8+
/**
9+
* Point-in-time reference for stable pagination.
10+
*
11+
* @see https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html
12+
*/
13+
readonly class Pit implements \Spameri\ElasticQuery\Entity\ArrayInterface
14+
{
15+
16+
public function __construct(
17+
public string $id,
18+
public string|null $keepAlive = null,
19+
)
20+
{
21+
}
22+
23+
24+
/**
25+
* @return array<string, string>
26+
*/
27+
public function toArray(): array
28+
{
29+
$array = ['id' => $this->id];
30+
31+
if ($this->keepAlive !== null) {
32+
$array['keep_alive'] = $this->keepAlive;
33+
}
34+
35+
return $array;
36+
}
37+
38+
}

src/Options/Rescore.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Spameri\ElasticQuery\Options;
6+
7+
8+
/**
9+
* Rescore the top N hits with a secondary query.
10+
*
11+
* @see https://www.elastic.co/guide/en/elasticsearch/reference/current/filter-search-results.html#rescore
12+
*/
13+
class Rescore implements \Spameri\ElasticQuery\Entity\ArrayInterface
14+
{
15+
16+
public const SCORE_MODE_TOTAL = 'total';
17+
public const SCORE_MODE_MULTIPLY = 'multiply';
18+
public const SCORE_MODE_AVG = 'avg';
19+
public const SCORE_MODE_MAX = 'max';
20+
public const SCORE_MODE_MIN = 'min';
21+
22+
public function __construct(
23+
private \Spameri\ElasticQuery\Query\LeafQueryInterface $query,
24+
private int $windowSize,
25+
private float|null $queryWeight = null,
26+
private float|null $rescoreQueryWeight = null,
27+
private string|null $scoreMode = null,
28+
)
29+
{
30+
}
31+
32+
33+
/**
34+
* @return array<string, mixed>
35+
*/
36+
public function toArray(): array
37+
{
38+
$rescoreQuery = ['rescore_query' => $this->query->toArray()];
39+
40+
if ($this->queryWeight !== null) {
41+
$rescoreQuery['query_weight'] = $this->queryWeight;
42+
}
43+
44+
if ($this->rescoreQueryWeight !== null) {
45+
$rescoreQuery['rescore_query_weight'] = $this->rescoreQueryWeight;
46+
}
47+
48+
if ($this->scoreMode !== null) {
49+
$rescoreQuery['score_mode'] = $this->scoreMode;
50+
}
51+
52+
return [
53+
'window_size' => $this->windowSize,
54+
'query' => $rescoreQuery,
55+
];
56+
}
57+
58+
}

0 commit comments

Comments
 (0)