Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.93% covered (danger)
37.93%
55 / 145
20.00% covered (danger)
20.00%
4 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
FeatureIndex
37.93% covered (danger)
37.93%
55 / 145
20.00% covered (danger)
20.00%
4 / 20
920.85
0.00% covered (danger)
0.00%
0 / 1
 getLimit
n/a
0 / 0
n/a
0 / 0
0
 queryOptions
n/a
0 / 0
n/a
0 / 0
0
 removeFromIndex
n/a
0 / 0
n/a
0 / 0
0
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getPrimaryKeyColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canAnswer
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOrder
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 cachePurge
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onAfterInsert
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onAfterUpdate
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 onAfterRemove
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onAfterLoad
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAfterClear
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 find
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 findMulti
80.00% covered (warning)
80.00%
24 / 30
0.00% covered (danger)
0.00%
0 / 1
9.65
 filterResults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 found
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 foundMulti
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
110
 getCacheKeys
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 backingStoreFindMulti
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 cacheKey
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 cachedDbId
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3namespace Flow\Data\Index;
4
5use Flow\Data\Compactor;
6use Flow\Data\Compactor\FeatureCompactor;
7use Flow\Data\Compactor\ShallowCompactor;
8use Flow\Data\FlowObjectCache;
9use Flow\Data\Index;
10use Flow\Data\ObjectManager;
11use Flow\Data\ObjectMapper;
12use Flow\Data\ObjectStorage;
13use Flow\Exception\DataModelException;
14use Flow\Model\UUID;
15use MediaWiki\Json\FormatJson;
16use MediaWiki\WikiMap\WikiMap;
17
18/**
19 * Index objects with equal features($indexedColumns) into the same buckets.
20 */
21abstract class FeatureIndex implements Index {
22
23    /**
24     * @var FlowObjectCache
25     */
26    protected $cache;
27
28    /**
29     * @var ObjectStorage
30     */
31    protected $storage;
32
33    /**
34     * @var ObjectMapper
35     */
36    protected $mapper;
37
38    /**
39     * @var string
40     */
41    protected $prefix;
42
43    /**
44     * @var Compactor
45     */
46    protected $rowCompactor;
47
48    /**
49     * @var string[]
50     */
51    protected $indexed;
52
53    /**
54     * @var string[] The indexed columns in alphabetical order. This is
55     *  ordered so that cache keys can be generated in a stable manner.
56     */
57    protected $indexedOrdered;
58
59    /**
60     * @var array
61     */
62    protected $options;
63
64    /**
65     * @inheritDoc
66     */
67    abstract public function getLimit();
68
69    /**
70     * @return array The options used for querying self::$storage
71     */
72    abstract public function queryOptions();
73
74    /**
75     * @todo Similar, Could the cache key be passed in instead of $indexed?
76     * @param array $indexed The portion of $row that makes up the cache key
77     * @param array $row A single row of data to remove from its related feature bucket
78     */
79    abstract protected function removeFromIndex( array $indexed, array $row );
80
81    /**
82     * @param FlowObjectCache $cache
83     * @param ObjectStorage $storage
84     * @param ObjectMapper $mapper
85     * @param string $prefix Prefix to utilize for all cache keys
86     * @param string[] $indexedColumns List of columns to index
87     */
88    public function __construct(
89        FlowObjectCache $cache,
90        ObjectStorage $storage,
91        ObjectMapper $mapper,
92        $prefix,
93        array $indexedColumns
94    ) {
95        $this->cache = $cache;
96        $this->storage = $storage;
97        $this->mapper = $mapper;
98        $this->prefix = $prefix;
99        $this->rowCompactor = new FeatureCompactor( $indexedColumns );
100        $this->indexed = $indexedColumns;
101        // sort this and ksort in self::cacheKey to always have cache key
102        // fields in same order
103        sort( $indexedColumns );
104        $this->indexedOrdered = $indexedColumns;
105    }
106
107    /**
108     * @return string[] The list of columns to bucket database rows by in
109     *  the same order as provided to the constructor.
110     */
111    public function getPrimaryKeyColumns() {
112        return $this->indexed;
113    }
114
115    /**
116     * @inheritDoc
117     */
118    public function canAnswer( array $featureColumns, array $options ) {
119        sort( $featureColumns );
120        if ( $featureColumns !== $this->indexedOrdered ) {
121            return false;
122        }
123
124        // This can probably be moved to TopKIndex if it's not used
125        // by anything else.
126        if ( isset( $options['limit'] ) ) {
127            $max = $options['limit'];
128            if ( isset( $options['offset'] ) ) {
129                $max += $options['offset'];
130            }
131            if ( $max > $this->getLimit() ) {
132                return false;
133            }
134        }
135        return true;
136    }
137
138    /**
139     * Rows are first sorted based on the first term of the result, then ties
140     * are broken by evaluating the second term and so on.
141     *
142     * @return string[]|false The columns to sort by, or false if no sorting is defined
143     */
144    public function getSort() {
145        return $this->options['sort'] ?? false;
146    }
147
148    /**
149     * @inheritDoc
150     */
151    public function getOrder() {
152        if ( isset( $this->options['order'] ) && strtoupper( $this->options['order'] ) === 'ASC' ) {
153            return 'ASC';
154        } else {
155            return 'DESC';
156        }
157    }
158
159    /**
160     * Delete any feature bucket $object would be contained in from the cache
161     *
162     * @param object $object
163     * @param array $row
164     * @throws DataModelException
165     */
166    public function cachePurge( $object, array $row ) {
167        $indexed = ObjectManager::splitFromRow( $row, $this->indexed );
168        if ( !$indexed ) {
169            throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $row ), 'process-data' );
170        }
171        // We don't want to just remove this object from the index, then the index would be incorrect.
172        // We want to delete the bucket that contains this object.
173        $this->cache->delete( $this->cacheKey( $indexed ) );
174    }
175
176    /**
177     * @inheritDoc
178     */
179    public function onAfterInsert( $object, array $new, array $metadata ) {
180        $indexed = ObjectManager::splitFromRow( $new, $this->indexed );
181        // is un-indexable a bail-worthy occasion? Probably not but makes debugging easier
182        if ( !$indexed ) {
183            throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $new ), 'process-data' );
184        }
185        $compacted = $this->rowCompactor->compactRow( UUID::convertUUIDs( $new, 'alphadecimal' ) );
186        $this->removeFromIndex( $indexed, $compacted );
187    }
188
189    /**
190     * @inheritDoc
191     */
192    public function onAfterUpdate( $object, array $old, array $new, array $metadata ) {
193        $oldIndexed = ObjectManager::splitFromRow( $old, $this->indexed );
194        $newIndexed = ObjectManager::splitFromRow( $new, $this->indexed );
195        if ( !$oldIndexed ) {
196            throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $oldIndexed ), 'process-data' );
197        }
198        if ( !$newIndexed ) {
199            throw new DataModelException( 'Un-indexable row: ' . FormatJson::encode( $newIndexed ), 'process-data' );
200        }
201        $oldCompacted = $this->rowCompactor->compactRow( UUID::convertUUIDs( $old, 'alphadecimal' ) );
202        $newCompacted = $this->rowCompactor->compactRow( UUID::convertUUIDs( $new, 'alphadecimal' ) );
203        $oldIndexedForComparison = UUID::convertUUIDs( $oldIndexed, 'alphadecimal' );
204        $newIndexedForComparison = UUID::convertUUIDs( $newIndexed, 'alphadecimal' );
205        if ( ObjectManager::arrayEquals( $oldIndexedForComparison, $newIndexedForComparison ) ) {
206            if ( ObjectManager::arrayEquals( $oldCompacted, $newCompacted ) ) {
207                // Nothing changed in the index
208                return;
209            }
210            // object representation in feature bucket has changed
211            $this->removeFromIndex( $oldIndexed, $oldCompacted );
212        } else {
213            // object has moved from one feature bucket to another
214            $this->removeFromIndex( $oldIndexed, $oldCompacted );
215        }
216    }
217
218    /**
219     * @inheritDoc
220     */
221    public function onAfterRemove( $object, array $old, array $metadata ) {
222        $indexed = ObjectManager::splitFromRow( $old, $this->indexed );
223        if ( !$indexed ) {
224            throw new DataModelException( 'Unindexable row: ' . FormatJson::encode( $old ), 'process-data' );
225        }
226        $compacted = $this->rowCompactor->compactRow( UUID::convertUUIDs( $old, 'alphadecimal' ) );
227        $this->removeFromIndex( $indexed, $compacted );
228    }
229
230    /**
231     * @inheritDoc
232     */
233    public function onAfterLoad( $object, array $old ) {
234        // nothing to do
235    }
236
237    /**
238     * @inheritDoc
239     */
240    public function onAfterClear() {
241        // nothing to do
242    }
243
244    /**
245     * @inheritDoc
246     */
247    public function find( array $attributes, array $options = [] ) {
248        $results = $this->findMulti( [ $attributes ], $options );
249        return reset( $results );
250    }
251
252    /**
253     * @inheritDoc
254     */
255    public function findMulti( array $queries, array $options = [] ) {
256        if ( !$queries ) {
257            return [];
258        }
259
260        // get cache keys for all queries
261        $cacheKeys = $this->getCacheKeys( $queries );
262
263        // retrieve from cache (only query duplicate queries once)
264        // $fromCache will be an array containing compacted results as value and
265        // cache keys as key
266        $fromCache = $this->cache->getMulti( array_unique( $cacheKeys ) );
267
268        // figure out what queries were resolved in cache
269        // $keysFromCache will be an array where values are cache keys and keys
270        // are the same index as their corresponding $queries
271        // (intersect with $cacheKeys to guarantee order)
272        $keysFromCache = array_intersect( $cacheKeys, array_keys( $fromCache ) );
273
274        // filter out all queries that have been resolved from cache and fetch
275        // them from storage
276        // $fromStorage will be an array containing (expanded) results as value
277        // and indexes matching $query as key
278        $storageQueries = array_diff_key( $queries, $keysFromCache );
279        $fromStorage = [];
280        if ( $storageQueries ) {
281            $fromStorage = $this->backingStoreFindMulti( $storageQueries );
282            foreach ( $fromStorage as $idx => $resultFromStorage ) {
283                $key = $this->cacheKey( $storageQueries[$idx] );
284                $this->cache->set( $key, $resultFromStorage );
285            }
286        }
287
288        $results = $fromStorage;
289
290        // $queries may have had duplicates that we've ignored to minimize
291        // cache requests - now re-duplicate values from cache & match the
292        // results against their respective original keys in $queries
293        foreach ( $keysFromCache as $index => $cacheKey ) {
294            $results[$index] = $fromCache[$cacheKey];
295        }
296
297        // now that we have all data, both from cache & backing storage, filter
298        // out all data we don't need
299        $results = $this->filterResults( $results, $options );
300
301        // if we have no data from cache, there's nothing left - quit early
302        if ( !$fromCache ) {
303            return $results;
304        }
305
306        // because we may have combined data from 2 different sources, chances
307        // are the order of the data is no longer in sync with the order
308        // $queries were in - fix that by replacing $queries values with
309        // the corresponding $results value
310        // note that there may be missing results, hence the intersect ;)
311        $order = array_intersect_key( $queries, $results );
312        $results = array_replace( $order, $results );
313
314        $keyToQuery = [];
315        foreach ( $keysFromCache as $index => $key ) {
316            // all redundant data has been stripped, now expand all cache values
317            // (we're only doing this now to avoid expanding redundant data)
318            $fromCache[$key] = $results[$index];
319
320            // to expand rows, we'll need the $query info mapped to the cache
321            // key instead of the $query index
322            if ( !isset( $keyToQuery[$key] ) ) {
323                $keyToQuery[$key] = $queries[$index];
324                $keyToQuery[$key] = UUID::convertUUIDs( $keyToQuery[$key], 'alphadecimal' );
325            }
326        }
327
328        // expand and replace the stubs in $results with complete data
329        $fromCache = $this->rowCompactor->expandCacheResult( $fromCache, $keyToQuery );
330        foreach ( $keysFromCache as $index => $cacheKey ) {
331            $results[$index] = $fromCache[$cacheKey];
332        }
333
334        return $results;
335    }
336
337    /**
338     * Get rid of unneeded, according to the given $options.
339     *
340     * This is used to strip entries before expanding them;
341     * basically, at that point, we may only have a list of ids, which we need
342     * to expand (= fetch from cache) - don't want to do this for more than
343     * what is needed
344     *
345     * @param array[] $results
346     * @param array $options
347     * @return array[]
348     */
349    protected function filterResults( array $results, array $options = [] ) {
350        // Overriden in TopKIndex
351        return $results;
352    }
353
354    /**
355     * Returns a boolean true/false if the find()-operation for the given
356     * attributes has already been resolves and doesn't need to query any
357     * outside cache/database.
358     * Determining if a find() has not yet been resolved may be useful so that
359     * additional data may be loaded at once.
360     *
361     * @param array $attributes Attributes to find()
362     * @param array $options Options to find()
363     * @return bool
364     */
365    public function found( array $attributes, array $options = [] ) {
366        return $this->foundMulti( [ $attributes ], $options );
367    }
368
369    /**
370     * Returns a boolean true/false if the findMulti()-operation for the given
371     * attributes has already been resolves and doesn't need to query any
372     * outside cache/database.
373     * Determining if a find() has not yet been resolved may be useful so that
374     * additional data may be loaded at once.
375     *
376     * @param array $queries Queries to findMulti()
377     * @param array $options Options to findMulti()
378     * @return bool
379     */
380    public function foundMulti( array $queries, array $options = [] ) {
381        if ( !$queries ) {
382            return true;
383        }
384
385        // get cache keys for all queries
386        $cacheKeys = $this->getCacheKeys( $queries );
387
388        // check if cache has a way of identifying what's stored locally
389        if ( !method_exists( $this->cache, 'has' ) ) {
390            return false;
391        }
392
393        // check if keys matching given queries are already known in local cache
394        foreach ( $cacheKeys as $key ) {
395            // @phan-suppress-next-line PhanUndeclaredMethod Checked with method_exists above
396            if ( !$this->cache->has( $key ) ) {
397                return false;
398            }
399        }
400
401        $keyToQuery = [];
402        foreach ( $cacheKeys as $i => $key ) {
403            // These results will be merged into the query results, and as such need binary
404            // uuid's as would be received from storage
405            if ( !isset( $keyToQuery[$key] ) ) {
406                $keyToQuery[$key] = $queries[$i];
407            }
408        }
409
410        // retrieve from cache - this is cheap, it's is local storage
411        $cached = $this->cache->getMulti( $cacheKeys );
412        foreach ( $cached as $i => $result ) {
413            $limit = $options['limit'] ?? $this->getLimit();
414            $cached[$i] = array_splice( $result, 0, $limit );
415        }
416
417        // if we have a shallow compactor, the returned data are PKs of objects
418        // that need to be fetched too
419        if ( $this->rowCompactor instanceof ShallowCompactor ) {
420            // test of the keys to be expanded are already in local cache
421            $duplicator = $this->rowCompactor->getResultDuplicator( $cached, $keyToQuery );
422            $queries = $duplicator->getUniqueQueries();
423            if ( !$this->rowCompactor->getShallow()->foundMulti( $queries ) ) {
424                return false;
425            }
426        }
427
428        return true;
429    }
430
431    /**
432     * Build a map from cache key to its index in $queries.
433     *
434     * @param array $queries
435     * @return array Array of [query index => cache key]
436     * @throws DataModelException
437     */
438    protected function getCacheKeys( $queries ) {
439        $idxToKey = [];
440        foreach ( $queries as $idx => $query ) {
441            ksort( $query );
442            if ( array_keys( $query ) !== $this->indexedOrdered ) {
443                throw new DataModelException(
444                    'Cannot answer query for columns: ' . implode( ', ', array_keys( $queries[$idx] ) ), 'process-data'
445                );
446            }
447            $key = $this->cacheKey( $query );
448            $idxToKey[$idx] = $key;
449        }
450
451        return $idxToKey;
452    }
453
454    /**
455     * Query persistent storage for data not found in cache.  Note that this
456     * does not use the query options because an individual bucket contents is
457     * based on constructor options, and not query options.  Query options merely
458     * change what part of the bucket is returned(or if the query has to fail over
459     * to direct from storage due to being beyond the set of cached values).
460     *
461     * @param array $queries
462     * @return array
463     */
464    protected function backingStoreFindMulti( array $queries ) {
465        // query backing store
466        $options = $this->queryOptions();
467        $stored = $this->storage->findMulti( $queries, $options );
468        $results = [];
469
470        // map store results to cache key
471        foreach ( $stored as $idx => $rows ) {
472            if ( !$rows ) {
473                // Nothing found,  should we cache failures as well as success?
474                continue;
475            }
476            $results[$idx] = $rows;
477            unset( $queries[$idx] );
478        }
479
480        if ( count( $queries ) !== 0 ) {
481            // Log something about not finding everything?
482        }
483
484        return $results;
485    }
486
487    /**
488     * Generate the cache key representing the attributes
489     * @param array $attributes
490     * @return string
491     */
492    protected function cacheKey( array $attributes ) {
493        global $wgFlowCacheVersion;
494        foreach ( $attributes as $key => $attr ) {
495            if ( $attr instanceof UUID ) {
496                $attributes[$key] = $attr->getAlphadecimal();
497            } elseif ( strlen( $attr ) === UUID::BIN_LEN && substr( $key, -3 ) === '_id' ) {
498                $attributes[$key] = UUID::create( $attr )->getAlphadecimal();
499            }
500        }
501
502        // values in $attributes may not always be in the exact same order,
503        // which would lead to differences in cache key if we don't force that
504        ksort( $attributes );
505
506        return $this->cache->makeGlobalKey(
507            $this->prefix,
508            self::cachedDbId(),
509            md5( implode( ':', $attributes ) ),
510            $wgFlowCacheVersion
511        );
512    }
513
514    /**
515     * @return string The id of the database being cached
516     */
517    public static function cachedDbId() {
518        global $wgFlowDefaultWikiDb;
519        if ( $wgFlowDefaultWikiDb === false ) {
520            return WikiMap::getCurrentWikiDbDomain()->getId();
521        } else {
522            return $wgFlowDefaultWikiDb;
523        }
524    }
525}