Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.04% covered (success)
92.04%
104 / 113
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pager
92.04% covered (success)
92.04%
104 / 113
50.00% covered (danger)
50.00%
2 / 4
29.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
9
 getPage
80.95% covered (warning)
80.95%
34 / 42
0.00% covered (danger)
0.00%
0 / 1
11.84
 processPage
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
7
 makePagingLink
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Flow\Data\Pager;
4
5use Flow\Data\Index;
6use Flow\Data\ObjectManager;
7use Flow\Exception\InvalidInputException;
8
9/**
10 * Fetches paginated results from the OM provided in constructor
11 */
12class Pager {
13    private const VALID_DIRECTIONS = [ 'fwd', 'rev' ];
14    private const DEFAULT_DIRECTION = 'fwd';
15    private const DEFAULT_LIMIT = 1;
16    private const MAX_LIMIT = 500;
17    private const MAX_QUERIES = 4;
18
19    /**
20     * @var ObjectManager
21     */
22    protected $storage;
23
24    /**
25     * @var Index
26     */
27    protected $index;
28
29    /**
30     * @var array Results sorted by the values in this array
31     */
32    protected $sort;
33
34    /**
35     * @var array Map of column name to column value for equality query
36     */
37    protected $query;
38
39    /**
40     * @var array Options effecting the result such as `sort`, `order`, and `pager-limit`
41     */
42    protected $options;
43
44    /**
45     * @var string
46     */
47    protected $offsetKey;
48
49    /**
50     * @var bool Whether this pager uses ID fields
51     */
52    protected $useId;
53
54    public function __construct( ObjectManager $storage, array $query, array $options ) {
55        // not sure i like this
56        $this->storage = $storage;
57        $this->query = $query;
58        $this->options = $options + [
59            'pager-include-offset' => null,
60            'pager-offset' => null,
61            'pager-limit' => self::DEFAULT_LIMIT,
62            'pager-dir' => self::DEFAULT_DIRECTION,
63        ];
64
65        $this->options['pager-limit'] = intval( $this->options['pager-limit'] );
66        if ( !( $this->options['pager-limit'] > 0 && $this->options['pager-limit'] < self::MAX_LIMIT ) ) {
67            $this->options['pager-limit'] = self::DEFAULT_LIMIT;
68        }
69
70        if ( !in_array( $this->options['pager-dir'], self::VALID_DIRECTIONS ) ) {
71            $this->options['pager-dir'] = self::DEFAULT_DIRECTION;
72        }
73
74        $indexOptions = [
75            'limit' => $this->options['pager-limit']
76        ];
77        if ( isset( $this->options['sort'] ) && isset( $this->options['order'] ) ) {
78            $indexOptions += [
79                'sort' => [ $this->options['sort'] ],
80                'order' => $this->options['order'],
81            ];
82        }
83        $this->sort = $storage->getIndexFor(
84            array_keys( $query ),
85            $indexOptions
86        )->getSort();
87
88        $useId = false;
89        foreach ( $this->sort as $val ) {
90            if ( substr( $val, -3 ) === '_id' ) {
91                $useId = true;
92            }
93            break;
94        }
95        $this->useId = $useId;
96
97        $this->offsetKey = $useId ? 'offset-id' : 'offset';
98    }
99
100    /**
101     * @param callable|null $filter Accepts an array of objects found in a single query
102     *  as its only argument and returns an array of accepted objects.
103     * @return PagerPage
104     */
105    public function getPage( $filter = null ) {
106        $numNeeded = $this->options['pager-limit'] + 1;
107        $storageOffsetKey = $this->useId ? 'offset-id' : 'offset-value';
108
109        $options = $this->options + [
110            // We need one item of leeway to determine if there are more items
111            'limit' => $numNeeded,
112            'offset-dir' => $this->options['pager-dir'],
113            $storageOffsetKey => $this->options['pager-offset'],
114            'include-offset' => $this->options['pager-include-offset'],
115        ];
116        $offset = $this->options['pager-offset'];
117        $results = [];
118        $queries = 0;
119
120        do {
121            if ( $queries === 2 ) {
122                // if we hit a third query ask for more items
123                $options['limit'] = min( self::MAX_LIMIT, $this->options['pager-limit'] * 5 );
124            }
125
126            // Retrieve results
127            $options = [
128                $storageOffsetKey => $offset,
129            ] + $options;
130            $found = $this->storage->find( $this->query, $options );
131
132            if ( !$found ) {
133                // nothing found
134                break;
135            }
136            $filtered = $filter ? $filter( $found ) : $found;
137            if ( $this->options['pager-dir'] === 'rev' ) {
138                // Paging A-Z with pager-offset F, pager-dir rev, pager-limit 2 gives
139                // DE on first query, BC on second, and A on third.  The output
140                // needs to be ABCDE
141                $results = array_merge( $filtered, $results );
142            } else {
143                $results = array_merge( $results, $filtered );
144            }
145
146            if ( count( $found ) !== $options['limit'] ) {
147                // last page
148                break;
149            }
150
151            // setup offset for next query
152            if ( $this->options['pager-dir'] === 'rev' ) {
153                $last = reset( $found );
154            } else {
155                $last = end( $found );
156            }
157            $offset = $this->storage->serializeOffset( $last, $this->sort );
158
159        } while ( count( $results ) < $numNeeded && ++$queries < self::MAX_QUERIES );
160
161        if ( $queries >= self::MAX_QUERIES ) {
162            $count = count( $results );
163            $limit = $this->options['pager-limit'];
164            wfDebugLog(
165                'Flow',
166                __METHOD__ . "Reached maximum of $queries queries with $count results of $limit " .
167                    "requested with query of " . json_encode( $this->query ) . ' and options ' .
168                    json_encode( $options )
169            );
170        }
171
172        if ( $results ) {
173            return $this->processPage( $results );
174        } else {
175            return new PagerPage( [], [], $this );
176        }
177    }
178
179    /**
180     * @param array $results
181     * @return PagerPage
182     * @throws InvalidInputException
183     */
184    protected function processPage( array $results ) {
185        $pagingLinks = [];
186
187        // Retrieve paging links
188        if ( $this->options['pager-dir'] === 'fwd' ) {
189            if ( count( $results ) > $this->options['pager-limit'] ) {
190                // We got extra, another page exists
191                $results = array_slice( $results, 0, $this->options['pager-limit'] );
192                $pagingLinks['fwd'] = $this->makePagingLink(
193                    'fwd',
194                    end( $results ),
195                    $this->options['pager-limit']
196                );
197            }
198
199            if ( $this->options['pager-offset'] !== null ) {
200                $pagingLinks['rev'] = $this->makePagingLink(
201                    'rev',
202                    reset( $results ),
203                    $this->options['pager-limit']
204                );
205            }
206        } elseif ( $this->options['pager-dir'] === 'rev' ) {
207            if ( count( $results ) > $this->options['pager-limit'] ) {
208                // We got extra, another page exists
209                $results = array_slice( $results, -$this->options['pager-limit'] );
210                $pagingLinks['rev'] = $this->makePagingLink(
211                    'rev',
212                    reset( $results ),
213                    $this->options['pager-limit']
214                );
215            }
216
217            if ( $this->options['pager-offset'] !== null ) {
218                $pagingLinks['fwd'] = $this->makePagingLink(
219                    'fwd',
220                    end( $results ),
221                    $this->options['pager-limit']
222                );
223            }
224        } else {
225            throw new InvalidInputException( "Unrecognised direction " . $this->options['pager-dir'], 'invalid-input' );
226        }
227
228        return new PagerPage( $results, $pagingLinks, $this );
229    }
230
231    /**
232     * @param string $direction
233     * @param object $object
234     * @param int $pageLimit
235     * @return array
236     */
237    protected function makePagingLink( $direction, $object, $pageLimit ) {
238        $return = [
239            'offset-dir' => $direction,
240            'limit' => $pageLimit,
241            $this->offsetKey => $this->storage->serializeOffset( $object, $this->sort ),
242        ];
243        if ( isset( $this->options['sortby'] ) ) {
244            $return['sortby'] = $this->options['sortby'];
245        }
246        return $return;
247    }
248}