Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.17% covered (danger)
47.17%
100 / 212
23.53% covered (danger)
23.53%
4 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
TreeRepository
47.17% covered (danger)
47.17%
100 / 212
23.53% covered (danger)
23.53%
4 / 17
450.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 cacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insert
96.72% covered (success)
96.72%
59 / 61
0.00% covered (danger)
0.00%
0 / 1
10
 deleteSubtreeCache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 findParent
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 findRootPaths
89.19% covered (warning)
89.19%
33 / 37
0.00% covered (danger)
0.00%
0 / 1
9.10
 findRootPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 findRoots
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 findRoot
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 fetchSubtreeIdentityMap
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 fetchSubtree
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 fetchFullTree
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fetchSubtreeNodeList
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 fetchSubtreeNodeListFromDb
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 fetchParentMap
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 fetchParentMapFromDb
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace Flow\Repository;
4
5use Flow\Data\FlowObjectCache;
6use Flow\Data\ObjectManager;
7use Flow\Data\Storage\DbStorage;
8use Flow\DbFactory;
9use Flow\Exception\DataModelException;
10use Flow\Model\UUID;
11use Wikimedia\Rdbms\DBQueryError;
12
13/**
14 * In SQL
15 *
16 * CREATE TABLE flow_tree_node (
17 *     descendant DECIMAL(39) UNSIGNED NOT NULL,
18 *     ancestor DECIMAL(39) UNSIGNED NULL,
19 *     depth SMALLINT UNSIGNED NOT NULL,
20 *     PRIMARY KEY ( ancestor, descendant ),
21 *     UNIQUE KEY ( descendant, depth )
22 * );
23 *
24 * In Memcache
25 *
26 * flow:tree:subtree:<descendant>
27 * flow:tree:rootpath:<descendant>
28 * flow:tree:parent:<descendant> - should we just use rootpath?
29 *
30 * Not sure how to handle topic splits with caching yet, i can imagine
31 * a number of potential race conditions for writing root paths and sub trees
32 * during a topic split
33 */
34class TreeRepository {
35
36    /**
37     * @var string
38     */
39    protected $tableName = 'flow_tree_node';
40
41    /**
42     * @var DbFactory
43     */
44    protected $dbFactory;
45
46    /**
47     * @var FlowObjectCache
48     */
49    protected $cache;
50
51    /**
52     * @param DbFactory $dbFactory Factory to source connection objects from
53     * @param FlowObjectCache $cache
54     */
55    public function __construct( DbFactory $dbFactory, FlowObjectCache $cache ) {
56        $this->dbFactory = $dbFactory;
57        $this->cache = $cache;
58    }
59
60    /**
61     * A helper function to generate cache keys for tree repository
62     * @param string $treeType
63     * @param UUID $uuid
64     * @return string
65     */
66    protected function cacheKey( $treeType, UUID $uuid ) {
67        return TreeCacheKey::build( $treeType, $uuid );
68    }
69
70    /**
71     * Insert a new tree node.  If ancestor === null then this node is a root.
72     *
73     * Also delete cache entries related to this tree.
74     * @param UUID $descendant
75     * @param UUID|null $ancestor
76     * @return true
77     * @throws DataModelException
78     */
79    public function insert( UUID $descendant, ?UUID $ancestor = null ) {
80        $this->cache->delete( $this->cacheKey( 'subtree', $descendant ) );
81        $this->cache->delete( $this->cacheKey( 'parent', $descendant ) );
82        $this->cache->delete( $this->cacheKey( 'rootpath', $descendant ) );
83
84        if ( $ancestor === null ) {
85            $path = [ $descendant ];
86        } else {
87            $path = $this->findRootPath( $ancestor );
88            $path[] = $descendant;
89        }
90        $this->deleteSubtreeCache( $descendant, $path );
91
92        $dbw = $this->dbFactory->getDB( DB_PRIMARY );
93        $queryBuilder = $dbw->newInsertQueryBuilder()
94            ->insertInto( $this->tableName )
95            ->row( [
96                'tree_descendant_id' => $descendant->getBinary(),
97                'tree_ancestor_id' => $descendant->getBinary(),
98                'tree_depth' => 0,
99            ] )
100            ->caller( __METHOD__ );
101        DbStorage::maybeSetInsertIgnore( $queryBuilder );
102        $queryBuilder->execute();
103
104        $ok = true;
105        if ( $ancestor !== null ) {
106            try {
107                if ( defined( 'MW_PHPUNIT_TEST' ) && $dbw->getType() == 'mysql' ) {
108                    /*
109                     * Combination of MW unit tests + MySQL DB is known to cause
110                     * query failures of code 1137, so instead of executing a
111                     * known bad query, let's just consider it failed right away
112                     * (and let catch statement deal with it)
113                     */
114                    throw new DBQueryError( $dbw, 'Prevented execution of known bad query', 1137, '', __METHOD__ );
115                }
116
117                $insertOptions = DbStorage::useInsertIgnore() ? [ 'IGNORE' ] : [];
118
119                $dbw->insertSelect(
120                    $this->tableName,
121                    $this->tableName,
122                    [
123                        'tree_descendant_id' => $dbw->addQuotes( $descendant->getBinary() ),
124                        'tree_ancestor_id' => 'tree_ancestor_id',
125                        'tree_depth' => 'tree_depth + 1',
126                    ],
127                    [
128                        'tree_descendant_id' => $ancestor->getBinary(),
129                    ],
130                    __METHOD__,
131                    $insertOptions
132                );
133            } catch ( DBQueryError $e ) {
134                /*
135                 * insertSelect won't work on temporary tables (as used for MW
136                 * unit tests), because it refers to the same table twice, in
137                 * one query.
138                 * In this case, we'll do a separate select & insert. This used
139                 * to always be detected via the DBQueryError, but it can also
140                 * return false from insertSelect.
141                 *
142                 * @see https://dev.mysql.com/doc/refman/5.0/en/temporary-table-problems.html
143                 * @see http://dba.stackexchange.com/questions/45270/mysql-error-1137-hy000-at-line-9-cant-reopen-table-temp-table
144                 */
145                if ( $e->errno === 1137 ) {
146                    $rows = $dbw->newSelectQueryBuilder()
147                        ->select( [ 'tree_depth', 'tree_ancestor_id' ] )
148                        ->from( $this->tableName )
149                        ->where( [ 'tree_descendant_id' => $ancestor->getBinary() ] )
150                        ->caller( __METHOD__ )
151                        ->fetchResultSet();
152
153                    foreach ( $rows as $row ) {
154                        $queryBuilder = $dbw->newInsertQueryBuilder()
155                            ->insertInto( $this->tableName )
156                            ->row( [
157                                'tree_descendant_id' => $descendant->getBinary(),
158                                'tree_ancestor_id' => $row->tree_ancestor_id,
159                                'tree_depth' => $row->tree_depth + 1,
160                            ] )
161                            ->caller( __METHOD__ );
162                        DbStorage::maybeSetInsertIgnore( $queryBuilder );
163                        $queryBuilder->execute();
164                    }
165                } else {
166                    $ok = false;
167                }
168            }
169        }
170
171        if ( !$ok ) {
172            throw new DataModelException( 'Failed inserting new tree node', 'process-data' );
173        }
174
175        return true;
176    }
177
178    protected function deleteSubtreeCache( UUID $descendant, array $rootPath ) {
179        foreach ( $rootPath as $subtreeRoot ) {
180            $cacheKey = $this->cacheKey( 'subtree', $subtreeRoot );
181            $this->cache->delete( $cacheKey );
182        }
183    }
184
185    /**
186     * Deletes a descendant from the tree repo.
187     */
188    public function delete( UUID $descendant ) {
189        $dbw = $this->dbFactory->getDB( DB_PRIMARY );
190        $dbw->newDeleteQueryBuilder()
191            ->deleteFrom( $this->tableName )
192            ->where( [
193                'tree_descendant_id' => $descendant->getBinary(),
194            ] )
195            ->caller( __METHOD__ )
196            ->execute();
197
198        $subtreeKey = $this->cacheKey( 'subtree', $descendant );
199        $parentKey = $this->cacheKey( 'parent', $descendant );
200        $pathKey = $this->cacheKey( 'rootpath', $descendant );
201
202        $this->cache->delete( $subtreeKey );
203        $this->cache->delete( $parentKey );
204        $this->cache->delete( $pathKey );
205    }
206
207    public function findParent( UUID $descendant ) {
208        $map = $this->fetchParentMap( [ $descendant ] );
209        return $map[$descendant->getAlphadecimal()] ?? null;
210    }
211
212    /**
213     * Given a list of nodes, find the path from each node to the root of its tree.
214     * the root must be the first element of the array, $node must be the last element.
215     * @param UUID[] $descendants Array of UUID objects to find the root paths for.
216     * @return UUID[][] Associative array, key is the post ID in hex, value is the path as an array.
217     */
218    public function findRootPaths( array $descendants ) {
219        // alphadecimal => cachekey
220        $cacheKeys = [];
221        // alphadecimal => cache result ( distance => parent uuid obj )
222        $cacheValues = [];
223        // list of binary values for db query
224        $missingValues = [];
225        // alphadecimal => distance => parent uuid obj
226        $paths = [];
227
228        foreach ( $descendants as $descendant ) {
229            $cacheKeys[$descendant->getAlphadecimal()] = $this->cacheKey( 'rootpath', $descendant );
230        }
231
232        $cacheResult = $this->cache->getMulti( array_values( $cacheKeys ) );
233        foreach ( $descendants as $descendant ) {
234            $alpha = $descendant->getAlphadecimal();
235            if ( isset( $cacheResult[$cacheKeys[$alpha]] ) ) {
236                $cacheValues[$alpha] = $cacheResult[$cacheKeys[$alpha]];
237            } else {
238                $missingValues[] = $descendant->getBinary();
239                $paths[$alpha] = [];
240            }
241        }
242
243        if ( !count( $missingValues ) ) {
244            return $cacheValues;
245        }
246
247        $dbr = $this->dbFactory->getDB( DB_REPLICA );
248        $res = $dbr->newSelectQueryBuilder()
249            ->select( [ 'tree_descendant_id', 'tree_ancestor_id', 'tree_depth' ] )
250            ->from( $this->tableName )
251            ->where( [
252                'tree_descendant_id' => $missingValues,
253            ] )
254            ->caller( __METHOD__ )
255            ->fetchResultSet();
256
257        if ( $res->numRows() === 0 ) {
258            return $cacheValues;
259        }
260
261        foreach ( $res as $row ) {
262            $alpha = UUID::create( $row->tree_descendant_id )->getAlphadecimal();
263            $paths[$alpha][$row->tree_depth] = UUID::create( $row->tree_ancestor_id );
264        }
265
266        foreach ( $paths as $descendantId => &$path ) {
267            if ( !$path ) {
268                $path = null;
269                continue;
270            }
271
272            // sort by reverse distance, so furthest away
273            // parent (root) is at position 0.
274            ksort( $path );
275            $path = array_reverse( $path );
276
277            $this->cache->set( $cacheKeys[$descendantId], $path );
278        }
279
280        return $paths + $cacheValues;
281    }
282
283    /**
284     * Finds the root path for a single post ID.
285     * @param UUID $descendant Post ID
286     * @return UUID[]|null Path to the root of that node.
287     */
288    public function findRootPath( UUID $descendant ) {
289        $paths = $this->findRootPaths( [ $descendant ] );
290
291        return $paths[$descendant->getAlphadecimal()] ?? null;
292    }
293
294    /**
295     * Finds the root posts of a list of posts.
296     * @param UUID[] $descendants Array of PostRevision objects to find roots for.
297     * @return UUID[] Associative array of post ID (as hex) to UUID object representing its root.
298     */
299    public function findRoots( array $descendants ) {
300        $paths = $this->findRootPaths( $descendants );
301        $roots = [];
302
303        foreach ( $descendants as $descendant ) {
304            $alpha = $descendant->getAlphadecimal();
305            if ( isset( $paths[$alpha] ) ) {
306                $roots[$alpha] = $paths[$alpha][0];
307            }
308        }
309
310        return $roots;
311    }
312
313    /**
314     * Given a specific child node find the associated root node
315     *
316     * @param UUID $descendant
317     * @return UUID
318     * @throws DataModelException
319     */
320    public function findRoot( UUID $descendant ) {
321        // To simplify caching we will work through the root path instead
322        // of caching our own value
323        $path = $this->findRootPath( $descendant );
324        if ( !$path ) {
325            throw new DataModelException(
326                $descendant->getAlphadecimal() . ' has no root post. Probably is a root post.',
327                'process-data'
328            );
329        }
330
331        return array_shift( $path );
332    }
333
334    /**
335     * Fetch a node and all its descendants. Children are returned in the
336     * same order they were inserted.
337     *
338     * @param UUID|UUID[] $roots
339     * @return array Multi-dimensional tree. The top level is a map from the uuid of a node
340     *  to attributes about that node.  The top level contains not just the parents, but all nodes
341     *  within this tree. Within each node there is a 'children' key that contains a map from
342     *  the child uuid's to references back to the top level of this identity map. As such this
343     *  result can be read either as a list or a tree.
344     * @throws DataModelException When invalid data is received from self::fetchSubtreeNodeList
345     */
346    public function fetchSubtreeIdentityMap( $roots ) {
347        $roots = ObjectManager::makeArray( $roots );
348        if ( !$roots ) {
349            return [];
350        }
351        $nodes = $this->fetchSubtreeNodeList( $roots );
352        if ( !$nodes ) {
353            throw new DataModelException(
354                'subtree node list should have at least returned root: ' . implode( ', ', $roots ),
355                'process-data'
356            );
357        } elseif ( count( $nodes ) === 1 ) {
358            $parentMap = $this->fetchParentMap( reset( $nodes ) );
359        } else {
360            $parentMap = $this->fetchParentMap( array_merge( ...array_values( $nodes ) ) );
361        }
362        $identityMap = [];
363        foreach ( $parentMap as $child => $parent ) {
364            if ( !array_key_exists( $child, $identityMap ) ) {
365                $identityMap[$child] = [ 'children' => [] ];
366            }
367            // Root nodes have no parent
368            if ( $parent !== null ) {
369                $identityMap[$parent->getAlphadecimal()]['children'][$child] =& $identityMap[$child];
370            }
371        }
372        foreach ( array_keys( $identityMap ) as $parent ) {
373            ksort( $identityMap[$parent]['children'] );
374        }
375
376        return $identityMap;
377    }
378
379    public function fetchSubtree( UUID $root ) {
380        $identityMap = $this->fetchSubtreeIdentityMap( $root );
381        if ( !isset( $identityMap[$root->getAlphadecimal()] ) ) {
382            throw new DataModelException( 'No root exists in the identityMap', 'process-data' );
383        }
384
385        return $identityMap[$root->getAlphadecimal()];
386    }
387
388    public function fetchFullTree( UUID $nodeId ) {
389        return $this->fetchSubtree( $this->findRoot( $nodeId ) );
390    }
391
392    /**
393     * Return the id's of all nodes which are a descendant of provided roots
394     *
395     * @param UUID[] $roots
396     * @return array map from root id to its descendant list
397     * @throws \Flow\Exception\InvalidInputException
398     */
399    public function fetchSubtreeNodeList( array $roots ) {
400        $list = new MultiGetList( $this->cache );
401        $res = $list->get(
402            'subtree',
403            $roots,
404            [ $this, 'fetchSubtreeNodeListFromDb' ]
405        );
406        // $idx is a binary UUID
407        $retval = [];
408        foreach ( $res as $idx => $val ) {
409            $retval[UUID::create( $idx )->getAlphadecimal()] = $val;
410        }
411        return $retval;
412    }
413
414    public function fetchSubtreeNodeListFromDb( array $roots ) {
415        $res = $this->dbFactory->getDB( DB_REPLICA )->newSelectQueryBuilder()
416            ->select( [ 'tree_ancestor_id', 'tree_descendant_id' ] )
417            ->from( $this->tableName )
418            ->where( [
419                'tree_ancestor_id' => UUID::convertUUIDs( $roots ),
420            ] )
421            ->caller( __METHOD__ )
422            ->fetchResultSet();
423        $nodes = [];
424        foreach ( $res as $node ) {
425            $ancestor = UUID::create( $node->tree_ancestor_id );
426            $descendant = UUID::create( $node->tree_descendant_id );
427            $nodes[$ancestor->getAlphadecimal()][$descendant->getAlphadecimal()] = $descendant;
428        }
429
430        return $nodes;
431    }
432
433    /**
434     * Fetch the id of the immediate parent node of all ids in $nodes.  Non-existent
435     * nodes are not represented in the result set.
436     * @param array $nodes
437     * @return array
438     */
439    public function fetchParentMap( array $nodes ) {
440        $list = new MultiGetList( $this->cache );
441        return $list->get(
442            'parent',
443            $nodes,
444            [ $this, 'fetchParentMapFromDb' ]
445        );
446    }
447
448    /**
449     * @param UUID[] $nodes
450     * @return UUID[]
451     * @throws DataModelException
452     */
453    public function fetchParentMapFromDb( array $nodes ) {
454        // Find out who the parent is for those nodes
455        $dbr = $this->dbFactory->getDB( DB_REPLICA );
456        $res = $dbr->newSelectQueryBuilder()
457            ->select( [ 'tree_ancestor_id', 'tree_descendant_id' ] )
458            ->from( $this->tableName )
459            ->where( [
460                'tree_descendant_id' => UUID::convertUUIDs( $nodes ),
461                'tree_depth' => 1,
462            ] )
463            ->caller( __METHOD__ )
464            ->fetchResultSet();
465        $result = [];
466        foreach ( $res as $node ) {
467            if ( isset( $result[$node->tree_descendant_id] ) ) {
468                throw new DataModelException( 'Already have a parent for ' . $node->tree_descendant_id, 'process-data' );
469            }
470            $descendant = UUID::create( $node->tree_descendant_id );
471            $result[$descendant->getAlphadecimal()] = UUID::create( $node->tree_ancestor_id );
472        }
473        foreach ( $nodes as $node ) {
474            if ( !isset( $result[$node->getAlphadecimal()] ) ) {
475                // $node is a root, it has no parent
476                $result[$node->getAlphadecimal()] = null;
477            }
478        }
479
480        return $result;
481    }
482}