1<?php
2
3namespace Elastica;
4
5use Elastica\Exception\ClientException;
6use Elastica\Exception\ConnectionException;
7use Elastica\Exception\InvalidException;
8use Elastica\Exception\ResponseException;
9use Elastica\Query\AbstractQuery;
10use Elastica\Query\MatchAll;
11use Elastica\ResultSet\BuilderInterface;
12use Elastica\ResultSet\DefaultBuilder;
13use Elastica\Suggest\AbstractSuggest;
14
15/**
16 * Elastica search object.
17 *
18 * @author   Nicolas Ruflin <spam@ruflin.com>
19 * @phpstan-import-type TCreateQueryArgs from Query
20 */
21class Search
22{
23    /*
24     * Options
25     */
26    public const OPTION_SEARCH_TYPE = 'search_type';
27    public const OPTION_ROUTING = 'routing';
28    public const OPTION_PREFERENCE = 'preference';
29    public const OPTION_VERSION = 'version';
30    public const OPTION_TIMEOUT = 'timeout';
31    public const OPTION_FROM = 'from';
32    public const OPTION_SIZE = 'size';
33    public const OPTION_SCROLL = 'scroll';
34    public const OPTION_SCROLL_ID = 'scroll_id';
35    public const OPTION_QUERY_CACHE = 'query_cache';
36    public const OPTION_TERMINATE_AFTER = 'terminate_after';
37    public const OPTION_SHARD_REQUEST_CACHE = 'request_cache';
38    public const OPTION_FILTER_PATH = 'filter_path';
39    public const OPTION_TYPED_KEYS = 'typed_keys';
40
41    /*
42     * Search types
43     */
44    public const OPTION_SEARCH_TYPE_DFS_QUERY_THEN_FETCH = 'dfs_query_then_fetch';
45    public const OPTION_SEARCH_TYPE_QUERY_THEN_FETCH = 'query_then_fetch';
46    public const OPTION_SEARCH_TYPE_SUGGEST = 'suggest';
47    public const OPTION_SEARCH_IGNORE_UNAVAILABLE = 'ignore_unavailable';
48
49    /**
50     * Array of indices names.
51     *
52     * @var string[]
53     */
54    protected $_indices = [];
55
56    /**
57     * @var Query
58     */
59    protected $_query;
60
61    /**
62     * @var array
63     */
64    protected $_options = [];
65
66    /**
67     * Client object.
68     *
69     * @var Client
70     */
71    protected $_client;
72
73    /**
74     * @var BuilderInterface|null
75     */
76    private $builder;
77
78    public function __construct(Client $client, ?BuilderInterface $builder = null)
79    {
80        $this->_client = $client;
81        $this->builder = $builder ?: new DefaultBuilder();
82    }
83
84    /**
85     * Adds a index to the list.
86     *
87     * @param Index $index Index object or string
88     *
89     * @throws InvalidException
90     */
91    public function addIndex($index): self
92    {
93        if ($index instanceof Index) {
94            $index = $index->getName();
95        } else {
96            \trigger_deprecation(
97                'ruflin/elastica',
98                '7.2.0',
99                'Passing a string as 1st argument to "%s()" is deprecated, pass an Index instance or use "addIndexByName" instead. It will throw a %s in 8.0.',
100                __METHOD__,
101                \TypeError::class
102            );
103        }
104
105        if (!\is_scalar($index)) {
106            throw new InvalidException('Invalid param type');
107        }
108
109        return $this->addIndexByName((string) $index);
110    }
111
112    /**
113     * Adds an index to the list.
114     */
115    public function addIndexByName(string $index): self
116    {
117        $this->_indices[] = $index;
118
119        return $this;
120    }
121
122    /**
123     * Add array of indices at once.
124     *
125     * @param Index[] $indices
126     */
127    public function addIndices(array $indices = []): self
128    {
129        foreach ($indices as $index) {
130            if (\is_string($index)) {
131                \trigger_deprecation(
132                    'ruflin/elastica',
133                    '7.2.0',
134                    'Passing a array of strings as 1st argument to "%s()" is deprecated, pass an array of Indexes or use "addIndicesByName" instead. It will throw a %s in 8.0.',
135                    __METHOD__,
136                    \TypeError::class
137                );
138                $this->addIndexByName($index);
139
140                continue;
141            }
142
143            if (!$index instanceof Index) {
144                throw new InvalidException('Invalid param type for addIndices(), expected Index[]');
145            }
146
147            $this->addIndex($index);
148        }
149
150        return $this;
151    }
152
153    /**
154     * @param string[] $indices
155     */
156    public function addIndicesByName(array $indices = []): self
157    {
158        foreach ($indices as $index) {
159            if (!\is_string($index)) {
160                throw new InvalidException('Invalid param type for addIndicesByName(), expected string[]');
161            }
162            $this->addIndexByName($index);
163        }
164
165        return $this;
166    }
167
168    /**
169     * @param AbstractQuery|AbstractSuggest|array|Collapse|Query|string|Suggest|null $query
170     * @phpstan-param TCreateQueryArgs $query
171     */
172    public function setQuery($query): self
173    {
174        $this->_query = Query::create($query);
175
176        return $this;
177    }
178
179    /**
180     * @param mixed $value
181     */
182    public function setOption(string $key, $value): self
183    {
184        $this->validateOption($key);
185
186        $this->_options[$key] = $value;
187
188        return $this;
189    }
190
191    public function setOptions(array $options): self
192    {
193        $this->clearOptions();
194
195        foreach ($options as $key => $value) {
196            $this->setOption($key, $value);
197        }
198
199        return $this;
200    }
201
202    public function clearOptions(): self
203    {
204        $this->_options = [];
205
206        return $this;
207    }
208
209    /**
210     * @param mixed $value
211     */
212    public function addOption(string $key, $value): self
213    {
214        $this->validateOption($key);
215
216        $this->_options[$key][] = $value;
217
218        return $this;
219    }
220
221    public function hasOption(string $key): bool
222    {
223        return isset($this->_options[$key]);
224    }
225
226    /**
227     * @throws InvalidException if the given key does not exists as an option
228     *
229     * @return mixed
230     */
231    public function getOption(string $key)
232    {
233        if (!$this->hasOption($key)) {
234            throw new InvalidException('Option '.$key.' does not exist');
235        }
236
237        return $this->_options[$key];
238    }
239
240    public function getOptions(): array
241    {
242        return $this->_options;
243    }
244
245    /**
246     * Return client object.
247     */
248    public function getClient(): Client
249    {
250        return $this->_client;
251    }
252
253    /**
254     * Return array of indices names.
255     *
256     * @return string[]
257     */
258    public function getIndices(): array
259    {
260        return $this->_indices;
261    }
262
263    public function hasIndices(): bool
264    {
265        return \count($this->_indices) > 0;
266    }
267
268    /**
269     * @param Index $index
270     */
271    public function hasIndex($index): bool
272    {
273        if ($index instanceof Index) {
274            $index = $index->getName();
275        } else {
276            \trigger_deprecation(
277                'ruflin/elastica',
278                '7.2.0',
279                'Passing a string as 1st argument to "%s()" is deprecated, pass an Index instance or use "hasIndexByName" instead. It will throw a %s in 8.0.',
280                __METHOD__,
281                \TypeError::class
282            );
283        }
284
285        return $this->hasIndexByName($index);
286    }
287
288    public function hasIndexByName(string $index): bool
289    {
290        return \in_array($index, $this->_indices, true);
291    }
292
293    public function getQuery(): Query
294    {
295        if (null === $this->_query) {
296            $this->_query = new Query(new MatchAll());
297        }
298
299        return $this->_query;
300    }
301
302    /**
303     * Creates new search object.
304     */
305    public static function create(SearchableInterface $searchObject): Search
306    {
307        return $searchObject->createSearch();
308    }
309
310    /**
311     * Combines indices to the search request path.
312     */
313    public function getPath(): string
314    {
315        if (isset($this->_options[self::OPTION_SCROLL_ID])) {
316            return '_search/scroll';
317        }
318
319        return \implode(',', $this->getIndices()).'/_search';
320    }
321
322    /**
323     * Search in the set indices.
324     *
325     * @param AbstractQuery|AbstractSuggest|array|Collapse|Query|string|Suggest|null $query
326     * @phpstan-param TCreateQueryArgs $query
327     *
328     * @param array|int $options Limit or associative array of options (option=>value)
329     *
330     * @throws InvalidException
331     * @throws ClientException
332     * @throws ConnectionException
333     * @throws ResponseException
334     */
335    public function search($query = '', $options = null, string $method = Request::POST): ResultSet
336    {
337        $this->setOptionsAndQuery($options, $query);
338
339        $query = $this->getQuery();
340        $path = $this->getPath();
341
342        $params = $this->getOptions();
343
344        // Send scroll_id via raw HTTP body to handle cases of very large (> 4kb) ids.
345        if ('_search/scroll' === $path) {
346            $data = [self::OPTION_SCROLL_ID => $params[self::OPTION_SCROLL_ID]];
347            unset($params[self::OPTION_SCROLL_ID]);
348        } else {
349            $data = $query->toArray();
350        }
351
352        $response = $this->getClient()->request($path, $method, $data, $params);
353
354        return $this->builder->buildResultSet($response, $query);
355    }
356
357    /**
358     * @param array|Query|Query\AbstractQuery|string $query
359     * @param bool                                   $fullResult By default only the total hit count is returned. If set to true, the full ResultSet including aggregations is returned
360     *
361     * @throws ClientException
362     * @throws ConnectionException
363     * @throws ResponseException
364     *
365     * @return int|ResultSet
366     */
367    public function count($query = '', bool $fullResult = false, string $method = Request::POST)
368    {
369        $this->setOptionsAndQuery(null, $query);
370
371        // Clone the object as we do not want to modify the original query.
372        $query = clone $this->getQuery();
373        $query->setSize(0);
374        $query->setTrackTotalHits(true);
375
376        $path = $this->getPath();
377
378        $response = $this->getClient()->request(
379            $path,
380            $method,
381            $query->toArray(),
382            [self::OPTION_SEARCH_TYPE => self::OPTION_SEARCH_TYPE_QUERY_THEN_FETCH]
383        );
384        $resultSet = $this->builder->buildResultSet($response, $query);
385
386        return $fullResult ? $resultSet : $resultSet->getTotalHits();
387    }
388
389    /**
390     * @param array|int                                                              $options
391     * @param AbstractQuery|AbstractSuggest|array|Collapse|Query|string|Suggest|null $query
392     * @phpstan-param TCreateQueryArgs $query
393     */
394    public function setOptionsAndQuery($options = null, $query = ''): self
395    {
396        if ('' !== $query) {
397            $this->setQuery($query);
398        }
399
400        if (\is_int($options)) {
401            \trigger_deprecation('ruflin/elastica', '7.1.3', 'Passing an int as 1st argument to "%s()" is deprecated, pass an array with the key "size" instead. It will be removed in 8.0.', __METHOD__);
402            $this->getQuery()->setSize($options);
403        } elseif (\is_array($options)) {
404            if (isset($options['limit'])) {
405                $this->getQuery()->setSize($options['limit']);
406                unset($options['limit']);
407            }
408            if (isset($options['explain'])) {
409                $this->getQuery()->setExplain($options['explain']);
410                unset($options['explain']);
411            }
412            $this->setOptions($options);
413        }
414
415        return $this;
416    }
417
418    public function setSuggest(Suggest $suggest): self
419    {
420        return $this->setOptionsAndQuery([self::OPTION_SEARCH_TYPE_SUGGEST => 'suggest'], $suggest);
421    }
422
423    /**
424     * Returns the Scroll Iterator.
425     *
426     * @see Scroll
427     */
428    public function scroll(string $expiryTime = '1m'): Scroll
429    {
430        return new Scroll($this, $expiryTime);
431    }
432
433    public function getResultSetBuilder(): BuilderInterface
434    {
435        return $this->builder;
436    }
437
438    /**
439     * @throws InvalidException If the given key is not a valid option
440     */
441    protected function validateOption(string $key): void
442    {
443        switch ($key) {
444            case self::OPTION_SEARCH_TYPE:
445            case self::OPTION_ROUTING:
446            case self::OPTION_PREFERENCE:
447            case self::OPTION_VERSION:
448            case self::OPTION_TIMEOUT:
449            case self::OPTION_FROM:
450            case self::OPTION_SIZE:
451            case self::OPTION_SCROLL:
452            case self::OPTION_SCROLL_ID:
453            case self::OPTION_SEARCH_TYPE_SUGGEST:
454            case self::OPTION_SEARCH_IGNORE_UNAVAILABLE:
455            case self::OPTION_QUERY_CACHE:
456            case self::OPTION_TERMINATE_AFTER:
457            case self::OPTION_SHARD_REQUEST_CACHE:
458            case self::OPTION_FILTER_PATH:
459            case self::OPTION_TYPED_KEYS:
460                return;
461        }
462
463        throw new InvalidException('Invalid option '.$key);
464    }
465}
466