Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.14% covered (danger)
32.14%
27 / 84
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
TopKIndex
32.14% covered (danger)
32.14%
27 / 84
22.22% covered (danger)
22.22%
2 / 9
539.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 canAnswer
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
72
 getLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filterResults
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getOffsetLimit
33.33% covered (danger)
33.33%
8 / 24
0.00% covered (danger)
0.00%
0 / 1
54.67
 getOffsetFromOffsetValue
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 compareRowToOffsetValue
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 removeFromIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 queryOptions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Flow\Data\Index;
4
5use Flow\Data\Compactor\ShallowCompactor;
6use Flow\Data\FlowObjectCache;
7use Flow\Data\ObjectManager;
8use Flow\Data\ObjectMapper;
9use Flow\Data\ObjectStorage;
10use Flow\Exception\DataModelException;
11use Flow\Exception\InvalidParameterException;
12
13/**
14 * Holds the top k items with matching $indexed columns.  List is sorted and truncated to specified size.
15 */
16class TopKIndex extends FeatureIndex {
17    /**
18     * @var array
19     */
20    protected $options = [];
21
22    public function __construct(
23        FlowObjectCache $cache,
24        ObjectStorage $storage,
25        ObjectMapper $mapper,
26        $prefix,
27        array $indexed,
28        array $options = []
29    ) {
30        if ( empty( $options['sort'] ) ) {
31            throw new InvalidParameterException( 'TopKIndex must be sorted' );
32        }
33
34        parent::__construct( $cache, $storage, $mapper, $prefix, $indexed );
35
36        $this->options = $options + [
37            'limit' => 500,
38            'order' => 'DESC',
39            'create' => static fn () => false,
40            'shallow' => null,
41        ];
42        $this->options['order'] = strtoupper( $this->options['order'] );
43
44        if ( !is_array( $this->options['sort'] ) ) {
45            $this->options['sort'] = [ $this->options['sort'] ];
46        }
47        if ( $this->options['shallow'] ) {
48            // TODO: perhaps we shouldn't even get a shallow option, just receive a proper compactor in
49            // FeatureIndex::__construct
50            $this->rowCompactor = new ShallowCompactor(
51                $this->rowCompactor, $this->options['shallow'], $this->options['sort'] );
52        }
53    }
54
55    public function canAnswer( array $keys, array $options ) {
56        if ( !parent::canAnswer( $keys, $options ) ) {
57            return false;
58        }
59
60        if ( isset( $options['offset-id'] ) ||
61            ( isset( $options['offset-dir'] ) && $options['offset-dir'] !== 'fwd' )
62        ) {
63            return false;
64        }
65
66        if ( isset( $options['sort'] ) && isset( $options['order'] ) ) {
67            return ObjectManager::makeArray( $options['sort'] ) === $this->options['sort']
68                && strtoupper( $options['order'] ) === $this->options['order'];
69        }
70        return true;
71    }
72
73    public function getLimit() {
74        return $this->options['limit'];
75    }
76
77    /**
78     * @param array[] $results
79     * @param array $options
80     *
81     * @return array[]
82     */
83    protected function filterResults( array $results, array $options = [] ) {
84        foreach ( $results as $i => $result ) {
85            [ $offset, $limit ] = $this->getOffsetLimit( $result, $options );
86            $results[$i] = array_slice( $result, $offset, $limit, true );
87        }
88
89        return $results;
90    }
91
92    // TODO: This is only left for now to handle non-ID offsets (e.g. updated
93    // timestamps).
94    // This has always been broken once you query past the TopKIndex limit.
95
96    /**
97     * @param array $rows
98     * @param array $options
99     * @return array [offset, limit] 0-based index to start with and limit.
100     */
101    protected function getOffsetLimit( array $rows, array $options ) {
102        $limit = $options['limit'] ?? $this->getLimit();
103
104        $offsetValue = $options['offset-value'] ?? null;
105
106        $dir = 'fwd';
107        if (
108            isset( $options['offset-dir'] ) &&
109            $options['offset-dir'] === 'rev'
110        ) {
111            $dir = 'rev';
112        }
113
114        if ( $offsetValue === null ) {
115            $offset = $dir === 'fwd' ? 0 : count( $rows ) - $limit;
116            return [ $offset, $limit ];
117        }
118
119        $offset = $this->getOffsetFromOffsetValue( $rows, $offsetValue );
120        $includeOffset = isset( $options['include-offset'] ) && $options['include-offset'];
121        if ( $dir === 'fwd' ) {
122            if ( $includeOffset ) {
123                $startPos = $offset;
124            } else {
125                $startPos = $offset + 1;
126            }
127        } else {
128            $startPos = $offset - $limit;
129            if ( $includeOffset ) {
130                $startPos++;
131            }
132
133            if ( $startPos < 0 ) {
134                if (
135                    isset( $options['offset-elastic'] ) &&
136                    $options['offset-elastic'] === false
137                ) {
138                    // If non-elastic, then reduce the number of items shown commensurately
139                    $limit += $startPos;
140                }
141                $startPos = 0;
142            }
143        }
144
145        return [ $startPos, $limit ];
146    }
147
148    /**
149     * Returns the 0-indexed position of $offsetValue within $rows or throws a
150     * DataModelException if $offsetValue is not contained within $rows
151     *
152     * @todo seems wasteful to pass string offsetValue instead of exploding when it comes in
153     * @param array $rows Current bucket contents
154     * @param string $offsetValue
155     * @return int The position of $offsetValue within $rows
156     * @throws DataModelException When $offsetValue is not found within $rows
157     */
158    protected function getOffsetFromOffsetValue( array $rows, $offsetValue ) {
159        $rowIndex = 0;
160        $nextInOrder = $this->getOrder() === 'DESC' ? -1 : 1;
161        foreach ( $rows as $row ) {
162            $comparisonValue = $this->compareRowToOffsetValue( $row, $offsetValue );
163            if ( $comparisonValue === 0 || $comparisonValue === $nextInOrder ) {
164                return $rowIndex;
165            }
166            $rowIndex++;
167        }
168
169        throw new DataModelException( 'Unable to find specified offset in query results', 'process-data' );
170    }
171
172    /**
173     * @param array $row Row to compare to
174     * @param string $offsetValue Value to compare to.  For instance, a timestamp if we
175     *  want all rows before/after that timestamp.  This consists of values for each field
176     *  we sort by, delimited by |.
177     *
178     * @return int An integer less than, equal to, or greater than zero
179     *  if $row is considered to be respectively less than, equal to, or
180     *  greater than $offsetValue
181     *
182     * @throws DataModelException When the index does not support offset values due to
183     *  having an undefined sort order.
184     */
185    public function compareRowToOffsetValue( array $row, $offsetValue ) {
186        $sortFields = $this->getSort();
187        $splitOffsetValue = explode( '|', $offsetValue );
188        $fieldIndex = 0;
189
190        if ( $sortFields === false ) {
191            throw new DataModelException( 'This Index implementation does not support offset values',
192                'process-data' );
193        }
194
195        foreach ( $sortFields as $field ) {
196            $valueInRow = $row[$field];
197            $offsetValuePart = $splitOffsetValue[$fieldIndex];
198
199            if ( $valueInRow > $offsetValuePart ) {
200                return 1;
201            } elseif ( $valueInRow < $offsetValuePart ) {
202                return -1;
203            }
204            ++$fieldIndex;
205        }
206
207        return 0;
208    }
209
210    protected function removeFromIndex( array $indexed, array $row ) {
211        $this->cache->delete( $this->cacheKey( $indexed ) );
212    }
213
214    /**
215     * INTERNAL: in 5.4 it can be protected.
216     *
217     * @return array
218     */
219    public function queryOptions() {
220        $options = [ 'LIMIT' => $this->options['limit'] ];
221
222        $orderBy = [];
223        $order = $this->options['order'];
224        // @phan-suppress-next-line PhanTypeNoPropertiesForeach
225        foreach ( $this->options['sort'] as $key ) {
226            $orderBy[] = "$key $order";
227        }
228        $options['ORDER BY'] = $orderBy;
229
230        return $options;
231    }
232}