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