Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.24% covered (warning)
63.24%
129 / 204
29.63% covered (danger)
29.63%
8 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionStorage
63.24% covered (warning)
63.24%
129 / 204
29.63% covered (danger)
29.63%
8 / 27
371.63
0.00% covered (danger)
0.00%
0 / 1
 joinTable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 joinField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 insertRelated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateRelated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeRelated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevType
n/a
0 / 0
n/a
0 / 0
0
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 find
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 findInternal
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 addRevTypeToQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 findMulti
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 fallbackFindMulti
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 findMultiInternal
47.62% covered (danger)
47.62%
10 / 21
0.00% covered (danger)
0.00%
0 / 1
37.29
 findRevId
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 findMostRecent
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 findRevIdReal
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 mergeExternalContent
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
5.05
 buildCompositeInCondition
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 insert
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 isUpdatingExistingRevisionContentAllowed
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
3.03
 processExternalStore
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 insertExternalStore
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 calcUpdates
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 update
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 remove
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getPrimaryKeyColumns
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 splitUpdate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace Flow\Data\Storage;
4
5use ExternalStore;
6use Flow\Data\ObjectManager;
7use Flow\Data\Utils\Merger;
8use Flow\Data\Utils\ResultDuplicator;
9use Flow\DbFactory;
10use Flow\Exception\DataModelException;
11use Flow\Model\UUID;
12use InvalidArgumentException;
13use Wikimedia\Rdbms\IDatabase;
14
15/**
16 * Abstract storage implementation for models extending from AbstractRevision
17 */
18abstract class RevisionStorage extends DbStorage {
19    /**
20     * @inheritDoc
21     */
22    protected $allowedUpdateColumns = [
23        'rev_mod_state',
24        'rev_mod_user_id',
25        'rev_mod_user_ip',
26        'rev_mod_user_wiki',
27        'rev_mod_timestamp',
28        'rev_mod_reason',
29    ];
30
31    /**
32     * @inheritDoc
33     *
34     * @todo This may not be necessary anymore since we don't update historical
35     * revisions ( flow_revision ) during moderation
36     */
37    protected $obsoleteUpdateColumns = [
38        'tree_orig_user_text',
39        'rev_user_text',
40        'rev_edit_user_text',
41        'rev_mod_user_text',
42        'rev_type_id',
43    ];
44
45    /** @var array|false */
46    protected $externalStore;
47
48    /**
49     * Get the table to join for the revision storage, empty string for none
50     * @return string
51     */
52    protected function joinTable() {
53        return '';
54    }
55
56    /**
57     * Get the column to join with flow_revision.rev_id, empty string for none
58     * @return string
59     */
60    protected function joinField() {
61        return '';
62    }
63
64    /**
65     * Insert to joinTable() upon revision insert
66     * @param array $row
67     * @return array
68     */
69    protected function insertRelated( array $row ) {
70        return $row;
71    }
72
73    /**
74     * Update to joinTable() upon revision update
75     * @param array $changes
76     * @param array $old
77     * @return array
78     */
79    protected function updateRelated( array $changes, array $old ) {
80        return $changes;
81    }
82
83    /**
84     * Remove from joinTable upone revision delete
85     * @param array $row
86     */
87    protected function removeRelated( array $row ) {
88    }
89
90    /**
91     * The revision type
92     * @return string
93     */
94    abstract protected function getRevType();
95
96    /**
97     * @param DbFactory $dbFactory
98     * @param array|false $externalStore List of external store servers available for insert
99     *  or false to disable. See $wgFlowExternalStore.
100     */
101    public function __construct( DbFactory $dbFactory, $externalStore ) {
102        parent::__construct( $dbFactory );
103        $this->externalStore = $externalStore;
104    }
105
106    /**
107     * Find one by specific attributes
108     * @todo this method can probably be generalized in parent class?
109     * @param array $attributes
110     * @param array $options
111     * @return mixed
112     */
113    public function find( array $attributes, array $options = [] ) {
114        $multi = $this->findMulti( [ $attributes ], $options );
115        return $multi ? reset( $multi ) : [];
116    }
117
118    /**
119     * @param array $attributes
120     * @param array $options
121     * @return array
122     */
123    protected function findInternal( array $attributes, array $options = [] ) {
124        $dbr = $this->dbFactory->getDB( DB_REPLICA );
125
126        if ( !$this->validateOptions( $options ) ) {
127            throw new InvalidArgumentException( "Validation error in database options" );
128        }
129
130        // Add rev_type if rev_type_id exists in query condition
131        $attributes = $this->addRevTypeToQuery( $attributes );
132
133        $tables = [ 'rev' => 'flow_revision' ];
134        $joins = [];
135        if ( $this->joinTable() ) {
136            $tables[] = $this->joinTable();
137            $joins = [ 'rev' => [ 'JOIN', $this->joinField() . ' = rev_id' ] ];
138        }
139
140        $res = $dbr->select(
141            $tables, '*', $this->preprocessSqlArray( $attributes ), __METHOD__, $options, $joins
142        );
143
144        $retval = [];
145        foreach ( $res as $row ) {
146            $row = UUID::convertUUIDs( (array)$row, 'alphadecimal' );
147            $retval[$row['rev_id']] = $row;
148        }
149        return $retval;
150    }
151
152    protected function addRevTypeToQuery( $query ) {
153        if ( isset( $query['rev_type_id'] ) ) {
154            $query['rev_type'] = $this->getRevType();
155        }
156        return $query;
157    }
158
159    public function findMulti( array $queries, array $options = [] ) {
160        if ( count( $queries ) < 3 ) {
161            $res = $this->fallbackFindMulti( $queries, $options );
162        } else {
163            $res = $this->findMultiInternal( $queries, $options );
164        }
165
166        return self::mergeExternalContent( $res );
167    }
168
169    protected function fallbackFindMulti( array $queries, array $options ) {
170        $result = [];
171        foreach ( $queries as $key => $attributes ) {
172            $result[$key] = $this->findInternal( $attributes, $options );
173        }
174        return $result;
175    }
176
177    protected function findMultiInternal( array $queries, array $options = [] ) {
178        $queriedKeys = array_keys( reset( $queries ) );
179        // The findMulti doesn't map well to SQL, basically we are asking to answer a bunch
180        // of queries. We can optimize those into a single query in a few select instances:
181        if ( isset( $options['LIMIT'] ) && $options['LIMIT'] == 1 ) {
182            // Find by primary key
183            if ( $options == [ 'LIMIT' => 1 ] &&
184                $queriedKeys === [ 'rev_id' ]
185            ) {
186                return $this->findRevId( $queries );
187            }
188
189            // Find most recent revision of a number of posts
190            if ( !isset( $options['OFFSET'] ) &&
191                $queriedKeys == [ 'rev_type_id' ] &&
192                isset( $options['ORDER BY'] ) &&
193                $options['ORDER BY'] === [ 'rev_id DESC' ]
194            ) {
195                return $this->findMostRecent( $queries );
196            }
197        }
198
199        // Fetch a list of revisions for each post
200        // @todo this is slow and inefficient.  Mildly better solution would be if
201        // the index can ask directly for just the list of rev_id instead of whole rows,
202        // but would still have the need to run a bunch of queries serially.
203        if ( count( $options ) === 2 &&
204            isset( $options['LIMIT'] ) && isset( $options['ORDER BY'] ) &&
205            $options['ORDER BY'] === [ 'rev_id DESC' ]
206        ) {
207            return $this->fallbackFindMulti( $queries, $options );
208        // unoptimizable query
209        } else {
210            wfDebugLog( 'Flow', __METHOD__
211                . ': Unoptimizable query for keys: '
212                . implode( ',', array_keys( $queriedKeys ) )
213                . ' with options '
214                . \FormatJson::encode( $options )
215            );
216            return $this->fallbackFindMulti( $queries, $options );
217        }
218    }
219
220    protected function findRevId( array $queries ) {
221        $duplicator = new ResultDuplicator( [ 'rev_id' ], 1 );
222        $pks = [];
223        foreach ( $queries as $idx => $query ) {
224            $query = UUID::convertUUIDs( (array)$query, 'alphadecimal' );
225            $duplicator->add( $query, $idx );
226            $id = $query['rev_id'];
227            $pks[$id] = UUID::create( $id )->getBinary();
228        }
229
230        return $this->findRevIdReal( $duplicator, $pks );
231    }
232
233    protected function findMostRecent( array $queries ) {
234        // SELECT MAX( rev_id ) AS rev_id
235        // FROM flow_tree_revision
236        // WHERE rev_type= 'post' AND rev_type_id IN (...)
237        // GROUP BY rev_type_id
238        $duplicator = new ResultDuplicator( [ 'rev_type_id' ], 1 );
239        foreach ( $queries as $idx => $query ) {
240            $query = UUID::convertUUIDs( (array)$query, 'alphadecimal' );
241            $duplicator->add( $query, $idx );
242        }
243
244        $dbr = $this->dbFactory->getDB( DB_REPLICA );
245        $res = $dbr->select(
246            [ 'flow_revision' ],
247            [ 'rev_id' => "MAX( 'rev_id' )" ],
248            [ 'rev_type' => $this->getRevType() ] + $this->preprocessSqlArray(
249                $this->buildCompositeInCondition( $dbr, $duplicator->getUniqueQueries() ) ),
250            __METHOD__,
251            [ 'GROUP BY' => 'rev_type_id' ]
252        );
253
254        $revisionIds = [];
255        foreach ( $res as $row ) {
256            $revisionIds[] = $row->rev_id;
257        }
258
259        // Due to the grouping and max, we cant reliably get a full
260        // columns info in the above query, forcing the join below
261        // rather than just querying flow_revision.
262        return $this->findRevIdReal( $duplicator, $revisionIds );
263    }
264
265    /**
266     * @param ResultDuplicator $duplicator
267     * @param array $revisionIds Binary strings representing revision uuid's
268     * @return array
269     */
270    protected function findRevIdReal( ResultDuplicator $duplicator, array $revisionIds ) {
271        if ( $revisionIds ) {
272            // SELECT * from flow_revision
273            // JOIN flow_tree_revision ON tree_rev_id = rev_id
274            // WHERE rev_id IN (...)
275            $dbr = $this->dbFactory->getDB( DB_REPLICA );
276
277            $tables = [ 'flow_revision' ];
278            $joins  = [];
279            if ( $this->joinTable() ) {
280                $tables['rev'] = $this->joinTable();
281                $joins = [ 'rev' => [ 'JOIN', "rev_id = " . $this->joinField() ] ];
282            }
283
284            $res = $dbr->select(
285                $tables,
286                '*',
287                [ 'rev_id' => $revisionIds ],
288                __METHOD__,
289                [],
290                $joins
291            );
292
293            foreach ( $res as $row ) {
294                $row = UUID::convertUUIDs( (array)$row, 'alphadecimal' );
295                $duplicator->merge( $row, [ $row ] );
296            }
297        }
298
299        return $duplicator->getResult();
300    }
301
302    /**
303     * Handle the injection of externalstore data into a revision
304     * row.  All rows exiting this method will have rev_content_url
305     * set to either null or the external url.  The rev_content
306     * field will be the final content (possibly compressed still)
307     *
308     * @param array $cacheResult 2d array of rows
309     * @return array 2d array of rows with content merged and rev_content_url populated
310     */
311    public static function mergeExternalContent( array $cacheResult ) {
312        foreach ( $cacheResult as &$source ) {
313            if ( $source === null ) {
314                // unanswered queries return null
315                continue;
316            }
317            foreach ( $source as &$row ) {
318                $flags = explode( ',', $row['rev_flags'] );
319                if ( in_array( 'external', $flags ) ) {
320                    $row['rev_content_url'] = $row['rev_content'];
321                    $row['rev_content'] = '';
322                } else {
323                    $row['rev_content_url'] = null;
324                }
325            }
326        }
327
328        return Merger::mergeMulti(
329            $cacheResult,
330            /* fromKey = */ 'rev_content_url',
331            /* callable = */ [ 'ExternalStore', 'batchFetchFromURLs' ],
332            /* name = */ 'rev_content',
333            /* default = */ ''
334        );
335    }
336
337    protected function buildCompositeInCondition( IDatabase $dbr, array $queries ) {
338        $keys = array_keys( reset( $queries ) );
339        $conditions = [];
340        if ( count( $keys ) === 1 ) {
341            // standard in condition: tree_rev_descendant_id IN (1,2...)
342            $key = reset( $keys );
343            foreach ( $queries as $query ) {
344                $conditions[$key][] = reset( $query );
345            }
346            return $conditions;
347        } else {
348            // composite in condition: ( foo = 1 AND bar = 2 ) OR ( foo = 1 AND bar = 3 )...
349            // Could be more efficient if composed as a range scan, but seems more complex than
350            // its benefit.
351            foreach ( $queries as $query ) {
352                $conditions[] = $dbr->makeList( $query, LIST_AND );
353            }
354            return $dbr->makeList( $conditions, LIST_OR );
355        }
356    }
357
358    public function insert( array $rows ) {
359        if ( !is_array( reset( $rows ) ) ) {
360            $rows = [ $rows ];
361        }
362
363        // Holds the subset of the row to go into the revision table
364        $revisions = [];
365
366        foreach ( $rows as $key => $row ) {
367            $row = $this->processExternalStore( $row );
368            $revisions[$key] = $this->splitUpdate( $row, 'rev' );
369        }
370
371        if ( $revisions ) {
372            $dbw = $this->dbFactory->getDB( DB_PRIMARY );
373            $dbw->newInsertQueryBuilder()
374                ->insertInto( 'flow_revision' )
375                ->rows( $this->preprocessNestedSqlArray( $revisions ) )
376                ->caller( __METHOD__ )
377                ->execute();
378        }
379
380        return $this->insertRelated( $rows );
381    }
382
383    /**
384     * Checks whether updating content for an existing revision is allowed.
385     * This is only needed for rare actions like fixing XSS.  Normally a new revision
386     * is made.
387     *
388     * Will throw if column configuration is not consistent
389     *
390     * @return bool True if and only if updating existing content is allowed
391     * @throws DataModelException
392     */
393    public function isUpdatingExistingRevisionContentAllowed() {
394        // All of these are required to do a consistent mechanical update.
395        $requiredColumnNames = [
396            'rev_content',
397            'rev_content_length',
398            'rev_flags',
399            'rev_previous_content_length',
400        ];
401
402        // compare required column names against allowedUpdateColumns
403        $diff = array_diff( $requiredColumnNames, $this->allowedUpdateColumns );
404
405        // we're able to update all columns we need: go ahead!
406        if ( !$diff ) {
407            return true;
408        }
409
410        // we're only able to update part of the columns required to update content
411        // @phan-suppress-next-line PhanImpossibleTypeComparison
412        if ( $diff !== $requiredColumnNames ) {
413            throw new DataModelException( "Allowed update column configuration is inconsistent",
414                'allowed-update-inconsistent' );
415        }
416
417        // content changes aren't allowed
418        return false;
419    }
420
421    /**
422     * If this is a new row (new rows should always have content) or part of an update
423     * involving a content change, inserts into external store.
424     * @param array $row
425     * @return array
426     */
427    protected function processExternalStore( array $row ) {
428        // Check if we need to insert new content
429        if (
430            $this->externalStore &&
431            isset( $row['rev_content'] )
432        ) {
433            $row = $this->insertExternalStore( $row );
434        }
435
436        // If a content url is available store that in the db
437        // instead of real content.
438        if ( isset( $row['rev_content_url'] ) ) {
439            $row['rev_content'] = $row['rev_content_url'];
440        }
441        unset( $row['rev_content_url'] );
442
443        return $row;
444    }
445
446    protected function insertExternalStore( array $row ) {
447        if ( $row['rev_content'] === null ) {
448            throw new DataModelException( "Must have data to write to external storage", 'process-data' );
449        }
450        $url = ExternalStore::insertWithFallback( $this->externalStore, $row['rev_content'] );
451        if ( !$url ) {
452            throw new DataModelException( "Unable to store text to external storage", 'process-data' );
453        }
454        $row['rev_content_url'] = $url;
455        if ( isset( $row['rev_flags'] ) && $row['rev_flags'] ) {
456            $row['rev_flags'] .= ',external';
457        } else {
458            $row['rev_flags'] = 'external';
459        }
460
461        return $row;
462    }
463
464    /**
465     * Gets the required updates.  Any changes to External Store will be reflected in
466     * the returned array.
467     *
468     * @param array $old Associative array mapping prior columns to old values
469     * @param array $new Associative array mapping updated columns to new values
470     *
471     * @return array Validated change set as associative array, mapping columns to
472     *   change to their new values
473     */
474    public function calcUpdates( array $old, array $new ) {
475        // First, see if there are any changes to content at all.
476        // If not, processExternalStore will know not to insert a useless row for
477        // unchanged content (if updating content is allowed).
478        $unvalidatedChangeset = ObjectManager::calcUpdatesWithoutValidation( $old, $new );
479
480        // We check here so if it's not allowed, we don't insert a wasted External
481        // Store entry, then throw an exception in the parent calcUpdates.
482        if ( $this->isUpdatingExistingRevisionContentAllowed() ) {
483            $unvalidatedChangeset = $this->processExternalStore( $unvalidatedChangeset );
484        }
485
486        // The parent calcUpdates does the validation that we're not changing a non-allowed
487        // field, regardless of whether explicitly passed in, or done by processExternalStore.
488        $validatedChangeset = parent::calcUpdates( [], $unvalidatedChangeset );
489        return $validatedChangeset;
490    }
491
492    /**
493     * This is to *UPDATE* a revision.  It should hardly ever be used.
494     * For the most part should insert a new revision.  This should only be called
495     * by maintenance scripts and (future) suppression features.
496     * It supports updating content, which is only intended for required mechanical
497     * transformations, such as XSS fixes.  However, since this is only intended for
498     * maintenance scripts, these columns must first be temporarily added to
499     * allowedUpdateColumns.
500     * @param array $old
501     * @param array $new
502     * @return bool
503     */
504    public function update( array $old, array $new ) {
505        $changeSet = $this->calcUpdates( $old, $new );
506
507        $rev = $this->splitUpdate( $changeSet, 'rev' );
508
509        if ( $rev ) {
510            $dbw = $this->dbFactory->getDB( DB_PRIMARY );
511            $dbw->newUpdateQueryBuilder()
512                ->update( 'flow_revision' )
513                ->set( $this->preprocessSqlArray( $rev ) )
514                ->where( $this->preprocessSqlArray( [ 'rev_id' => $old['rev_id'] ] ) )
515                ->caller( __METHOD__ )
516                ->execute();
517            if ( !$dbw->affectedRows() ) {
518                return false;
519            }
520        }
521        return (bool)$this->updateRelated( $changeSet, $old );
522    }
523
524    /**
525     * Revisions can only be removed for LIMITED circumstances,  in almost all cases
526     * the offending revision should be updated with appropriate suppression.
527     * Also note this doesnt delete the whole post, it just deletes the revision.
528     * The post will *always* exist in the tree structure, it will just show up as
529     * [deleted] or something
530     * @param array $row
531     */
532    public function remove( array $row ) {
533        $this->dbFactory->getDB( DB_PRIMARY )->newDeleteQueryBuilder()
534            ->deleteFrom( 'flow_revision' )
535            ->where( $this->preprocessSqlArray( [ 'rev_id' => $row['rev_id'] ] ) )
536            ->caller( __METHOD__ )
537            ->execute();
538        $this->removeRelated( $row );
539    }
540
541    /**
542     * Used to locate the index for a query by ObjectLocator::get()
543     * @return string[]
544     */
545    public function getPrimaryKeyColumns() {
546        return [ 'rev_id' ];
547    }
548
549    /**
550     * When retrieving revisions from DB, self::mergeExternalContent will be
551     * called to fetch the content. This could fail, resulting in the content
552     * being a 'false' value.
553     *
554     * @inheritDoc
555     */
556    public function validate( array $row ) {
557        return !isset( $row['rev_content'] ) || $row['rev_content'] !== false;
558    }
559
560    /**
561     * Gets all columns from $row that start with a given prefix and omits other
562     * columns.
563     *
564     * @param array $row Rows to split
565     * @param string $prefix
566     * @return array Remaining rows
567     */
568    protected function splitUpdate( array $row, $prefix = 'rev' ) {
569        $rev = [];
570        foreach ( $row as $key => $value ) {
571            $keyPrefix = strstr( $key, '_', true );
572            if ( $keyPrefix === $prefix ) {
573                $rev[$key] = $value;
574            }
575        }
576        return $rev;
577    }
578}