1<?php
2
3namespace Elastica;
4
5use Elastica\Exception\ClientException;
6use Elastica\Exception\ConnectionException;
7use Elastica\Exception\InvalidException;
8use Elastica\Exception\ResponseException;
9
10/**
11 * Scroll Iterator.
12 *
13 * @author Manuel Andreo Garcia <andreo.garcia@gmail.com>
14 *
15 * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html
16 */
17class Scroll implements \Iterator
18{
19    /**
20     * @var string
21     */
22    public $expiryTime;
23
24    /**
25     * @var Search
26     */
27    protected $_search;
28
29    /**
30     * @var string|null
31     */
32    protected $_nextScrollId;
33
34    /**
35     * @var ResultSet|null
36     */
37    protected $_currentResultSet;
38
39    /**
40     * 0: scroll<br>
41     * 1: scroll id.
42     * 2: ignore_unavailable.
43     *
44     * @var array
45     */
46    protected $_options = [null, null, null];
47
48    private $totalPages = 0;
49    private $currentPage = 0;
50
51    public function __construct(Search $search, string $expiryTime = '1m')
52    {
53        $this->_search = $search;
54        $this->expiryTime = $expiryTime;
55    }
56
57    /**
58     * Returns current result set.
59     *
60     * @see http://php.net/manual/en/iterator.current.php
61     */
62    public function current(): ResultSet
63    {
64        if (!$this->_currentResultSet) {
65            throw new InvalidException('Could not fetch the current ResultSet from an invalid iterator. Did you forget to call "valid()"?');
66        }
67
68        return $this->_currentResultSet;
69    }
70
71    /**
72     * Next scroll search.
73     *
74     * @see http://php.net/manual/en/iterator.next.php
75     *
76     * @throws ClientException
77     * @throws ConnectionException
78     * @throws ResponseException
79     */
80    public function next(): void
81    {
82        $this->_currentResultSet = null;
83        if ($this->currentPage < $this->totalPages) {
84            $this->_saveOptions();
85
86            $this->_search->setOption(Search::OPTION_SCROLL, $this->expiryTime);
87            $this->_search->setOption(Search::OPTION_SCROLL_ID, $this->_nextScrollId);
88
89            $this->_setScrollId($this->_search->search());
90
91            $this->_revertOptions();
92        } else {
93            // If there are no pages left, we do not need to query ES.
94            $this->clear();
95        }
96    }
97
98    /**
99     * Returns scroll id.
100     *
101     * @see http://php.net/manual/en/iterator.key.php
102     */
103    public function key(): ?string
104    {
105        return $this->_nextScrollId;
106    }
107
108    /**
109     * Returns true if current result set contains at least one hit.
110     *
111     * @see http://php.net/manual/en/iterator.valid.php
112     */
113    public function valid(): bool
114    {
115        return null !== $this->_nextScrollId;
116    }
117
118    /**
119     * Initial scroll search.
120     *
121     * @see http://php.net/manual/en/iterator.rewind.php
122     *
123     * @throws ClientException
124     * @throws ConnectionException
125     * @throws ResponseException
126     */
127    public function rewind(): void
128    {
129        // reset state
130        $this->_options = [null, null, null];
131        $this->currentPage = 0;
132
133        // initial search
134        $this->_saveOptions();
135
136        $this->_search->setOption(Search::OPTION_SCROLL, $this->expiryTime);
137        $this->_search->setOption(Search::OPTION_SCROLL_ID, null);
138        $this->_currentResultSet = null;
139        $this->_setScrollId($this->_search->search());
140
141        $this->_revertOptions();
142    }
143
144    /**
145     * Cleares the search context on ES and marks this Scroll instance as finished.
146     *
147     * @throws ClientException
148     * @throws ConnectionException
149     * @throws ResponseException
150     */
151    public function clear(): void
152    {
153        if (null !== $this->_nextScrollId) {
154            $this->_search->getClient()->request(
155                '_search/scroll',
156                Request::DELETE,
157                [Search::OPTION_SCROLL_ID => [$this->_nextScrollId]]
158            );
159
160            // Reset scroll ID so valid() returns false.
161            $this->_nextScrollId = null;
162        }
163    }
164
165    /**
166     * Prepares Scroll for next request.
167     *
168     * @throws ClientException
169     * @throws ConnectionException
170     * @throws ResponseException
171     */
172    protected function _setScrollId(ResultSet $resultSet): void
173    {
174        if (0 === $this->currentPage) {
175            $this->totalPages = $resultSet->count() > 0 ? \ceil($resultSet->getTotalHits() / $resultSet->count()) : 0;
176        }
177
178        $this->_currentResultSet = $resultSet;
179        ++$this->currentPage;
180        $this->_nextScrollId = null;
181        if ($resultSet->getResponse()->isOk()) {
182            $this->_nextScrollId = $resultSet->getResponse()->getScrollId();
183            if (0 === $resultSet->count()) {
184                $this->clear();
185            }
186        }
187    }
188
189    /**
190     * Save all search options manipulated by Scroll.
191     */
192    protected function _saveOptions(): void
193    {
194        if ($this->_search->hasOption(Search::OPTION_SCROLL)) {
195            $this->_options[0] = $this->_search->getOption(Search::OPTION_SCROLL);
196        }
197
198        if ($this->_search->hasOption(Search::OPTION_SCROLL_ID)) {
199            $this->_options[1] = $this->_search->getOption(Search::OPTION_SCROLL_ID);
200        }
201
202        if ($this->_search->hasOption(Search::OPTION_SEARCH_IGNORE_UNAVAILABLE)) {
203            $isNotInitial = (null !== $this->_options[2]);
204            $this->_options[2] = $this->_search->getOption(Search::OPTION_SEARCH_IGNORE_UNAVAILABLE);
205
206            // remove ignore_unavailable from options if not initial search
207            if ($isNotInitial) {
208                $searchOptions = $this->_search->getOptions();
209                unset($searchOptions[Search::OPTION_SEARCH_IGNORE_UNAVAILABLE]);
210                $this->_search->setOptions($searchOptions);
211            }
212        }
213    }
214
215    /**
216     * Revert search options to previously saved state.
217     */
218    protected function _revertOptions(): void
219    {
220        $this->_search->setOption(Search::OPTION_SCROLL, $this->_options[0]);
221        $this->_search->setOption(Search::OPTION_SCROLL_ID, $this->_options[1]);
222        if (null !== $this->_options[2]) {
223            $this->_search->setOption(Search::OPTION_SEARCH_IGNORE_UNAVAILABLE, $this->_options[2]);
224        }
225    }
226}
227