Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.69% covered (warning)
61.69%
124 / 201
29.63% covered (danger)
29.63%
8 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionStorage
61.69% covered (warning)
61.69%
124 / 201
29.63% covered (danger)
29.63%
8 / 27
410.32
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 / 15
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     */
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        $queryBuilder = $dbr->newSelectQueryBuilder()
134            ->select( '*' )
135            ->from( 'flow_revision' )
136            ->where( $this->preprocessSqlArray( $attributes ) )
137            ->options( $options )
138            ->caller( __METHOD__ );
139        if ( $this->joinTable() ) {
140            $queryBuilder->join( $this->joinTable(), null, $this->joinField() . ' = rev_id' );
141        }
142
143        $res = $queryBuilder->fetchResultSet();
144
145        $retval = [];
146        foreach ( $res as $row ) {
147            $row = UUID::convertUUIDs( (array)$row, 'alphadecimal' );
148            $retval[$row['rev_id']] = $row;
149        }
150        return $retval;
151    }
152
153    protected function addRevTypeToQuery( $query ) {
154        if ( isset( $query['rev_type_id'] ) ) {
155            $query['rev_type'] = $this->getRevType();
156        }
157        return $query;
158    }
159
160    public function findMulti( array $queries, array $options = [] ) {
161        if ( count( $queries ) < 3 ) {
162            $res = $this->fallbackFindMulti( $queries, $options );
163        } else {
164            $res = $this->findMultiInternal( $queries, $options );
165        }
166
167        return self::mergeExternalContent( $res );
168    }
169
170    protected function fallbackFindMulti( array $queries, array $options ) {
171        $result = [];
172        foreach ( $queries as $key => $attributes ) {
173            $result[$key] = $this->findInternal( $attributes, $options );
174        }
175        return $result;
176    }
177
178    protected function findMultiInternal( array $queries, array $options = [] ) {
179        $queriedKeys = array_keys( reset( $queries ) );
180        // The findMulti doesn't map well to SQL, basically we are asking to answer a bunch
181        // of queries. We can optimize those into a single query in a few select instances:
182        if ( isset( $options['LIMIT'] ) && $options['LIMIT'] == 1 ) {
183            // Find by primary key
184            if ( $options == [ 'LIMIT' => 1 ] &&
185                $queriedKeys === [ 'rev_id' ]
186            ) {
187                return $this->findRevId( $queries );
188            }
189
190            // Find most recent revision of a number of posts
191            if ( !isset( $options['OFFSET'] ) &&
192                $queriedKeys == [ 'rev_type_id' ] &&
193                isset( $options['ORDER BY'] ) &&
194                $options['ORDER BY'] === [ 'rev_id DESC' ]
195            ) {
196                return $this->findMostRecent( $queries );
197            }
198        }
199
200        // Fetch a list of revisions for each post
201        // @todo this is slow and inefficient.  Mildly better solution would be if
202        // the index can ask directly for just the list of rev_id instead of whole rows,
203        // but would still have the need to run a bunch of queries serially.
204        if ( count( $options ) === 2 &&
205            isset( $options['LIMIT'] ) && isset( $options['ORDER BY'] ) &&
206            $options['ORDER BY'] === [ 'rev_id DESC' ]
207        ) {
208            return $this->fallbackFindMulti( $queries, $options );
209        // unoptimizable query
210        } else {
211            wfDebugLog( 'Flow', __METHOD__
212                . ': Unoptimizable query for keys: '
213                . implode( ',', array_keys( $queriedKeys ) )
214                . ' with options '
215                . FormatJson::encode( $options )
216            );
217            return $this->fallbackFindMulti( $queries, $options );
218        }
219    }
220
221    protected function findRevId( array $queries ) {
222        $duplicator = new ResultDuplicator( [ 'rev_id' ], 1 );
223        $pks = [];
224        foreach ( $queries as $idx => $query ) {
225            $query = UUID::convertUUIDs( (array)$query, 'alphadecimal' );
226            $duplicator->add( $query, $idx );
227            $id = $query['rev_id'];
228            $pks[] = UUID::create( $id )->getBinary();
229        }
230
231        return $this->findRevIdReal( $duplicator, $pks );
232    }
233
234    protected function findMostRecent( array $queries ) {
235        // SELECT MAX( rev_id ) AS rev_id
236        // FROM flow_tree_revision
237        // WHERE rev_type= 'post' AND rev_type_id IN (...)
238        // GROUP BY rev_type_id
239        $duplicator = new ResultDuplicator( [ 'rev_type_id' ], 1 );
240        foreach ( $queries as $idx => $query ) {
241            $query = UUID::convertUUIDs( (array)$query, 'alphadecimal' );
242            $duplicator->add( $query, $idx );
243        }
244
245        $dbr = $this->dbFactory->getDB( DB_REPLICA );
246        $res = $dbr->newSelectQueryBuilder()
247            ->select( [ 'rev_id' => "MAX( 'rev_id' )" ] )
248            ->from( 'flow_revision' )
249            ->where( [ 'rev_type' => $this->getRevType() ] )
250            ->andWhere( $this->buildCompositeInCondition( $dbr, $duplicator->getUniqueQueries() ) )
251            ->groupBy( 'rev_type_id' )
252            ->caller( __METHOD__ )
253            ->fetchResultSet();
254
255        $revisionIds = [];
256        foreach ( $res as $row ) {
257            $revisionIds[] = $row->rev_id;
258        }
259
260        // Due to the grouping and max, we cant reliably get a full
261        // columns info in the above query, forcing the join below
262        // rather than just querying flow_revision.
263        return $this->findRevIdReal( $duplicator, $revisionIds );
264    }
265
266    /**
267     * @param ResultDuplicator $duplicator
268     * @param array $revisionIds Binary strings representing revision uuid's
269     * @return array
270     */
271    protected function findRevIdReal( ResultDuplicator $duplicator, array $revisionIds ) {
272        if ( $revisionIds ) {
273            // SELECT * from flow_revision
274            // JOIN flow_tree_revision ON tree_rev_id = rev_id
275            // WHERE rev_id IN (...)
276            $dbr = $this->dbFactory->getDB( DB_REPLICA );
277
278            $queryBuilder = $dbr->newSelectQueryBuilder()
279                ->select( '*' )
280                ->from( 'flow_revision' )
281                ->where( [ 'rev_id' => $revisionIds ] )
282                ->caller( __METHOD__ );
283            if ( $this->joinTable() ) {
284                $queryBuilder->join( $this->joinTable(), null, "rev_id = " . $this->joinField() );
285            }
286
287            $res = $queryBuilder->fetchResultSet();
288
289            foreach ( $res as $row ) {
290                $row = UUID::convertUUIDs( (array)$row, 'alphadecimal' );
291                $duplicator->merge( $row, [ $row ] );
292            }
293        }
294
295        return $duplicator->getResult();
296    }
297
298    /**
299     * Handle the injection of externalstore data into a revision
300     * row.  All rows exiting this method will have rev_content_url
301     * set to either null or the external url.  The rev_content
302     * field will be the final content (possibly compressed still)
303     *
304     * @param array $cacheResult 2d array of rows
305     * @return array 2d array of rows with content merged and rev_content_url populated
306     */
307    public static function mergeExternalContent( array $cacheResult ) {
308        foreach ( $cacheResult as &$source ) {
309            if ( $source === null ) {
310                // unanswered queries return null
311                continue;
312            }
313            foreach ( $source as &$row ) {
314                $flags = explode( ',', $row['rev_flags'] );
315                if ( in_array( 'external', $flags ) ) {
316                    $row['rev_content_url'] = $row['rev_content'];
317                    $row['rev_content'] = '';
318                } else {
319                    $row['rev_content_url'] = null;
320                }
321            }
322        }
323
324        return Merger::mergeMulti(
325            $cacheResult,
326            /* fromKey = */ 'rev_content_url',
327            /* callable = */ [ 'ExternalStore', 'batchFetchFromURLs' ],
328            /* name = */ 'rev_content',
329            /* default = */ ''
330        );
331    }
332
333    protected function buildCompositeInCondition( IReadableDatabase $dbr, array $queries ) {
334        $keys = array_keys( reset( $queries ) );
335        $conditions = [];
336        if ( count( $keys ) === 1 ) {
337            // standard in condition: tree_rev_descendant_id IN (1,2...)
338            $key = reset( $keys );
339            foreach ( $queries as $query ) {
340                $conditions[$key][] = reset( $query );
341            }
342            return $this->preprocessSqlArray( $conditions );
343        } else {
344            // composite in condition: ( foo = 1 AND bar = 2 ) OR ( foo = 1 AND bar = 3 )...
345            // Could be more efficient if composed as a range scan, but seems more complex than
346            // its benefit.
347            foreach ( $queries as $query ) {
348                $conditions[] = $dbr->andExpr( $this->preprocessSqlArray( $query ) );
349            }
350            return $dbr->orExpr( $conditions );
351        }
352    }
353
354    public function insert( array $rows ) {
355        if ( !is_array( reset( $rows ) ) ) {
356            $rows = [ $rows ];
357        }
358
359        // Holds the subset of the row to go into the revision table
360        $revisions = [];
361
362        foreach ( $rows as $key => $row ) {
363            $row = $this->processExternalStore( $row );
364            $revisions[$key] = $this->splitUpdate( $row, 'rev' );
365        }
366
367        if ( $revisions ) {
368            $dbw = $this->dbFactory->getDB( DB_PRIMARY );
369            $queryBuilder = $dbw->newInsertQueryBuilder()
370                ->insertInto( 'flow_revision' )
371                ->rows( $this->preprocessNestedSqlArray( $revisions ) )
372                ->caller( __METHOD__ );
373            DbStorage::maybeSetInsertIgnore( $queryBuilder );
374            $queryBuilder->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     */
528    public function remove( array $row ) {
529        $this->dbFactory->getDB( DB_PRIMARY )->newDeleteQueryBuilder()
530            ->deleteFrom( 'flow_revision' )
531            ->where( $this->preprocessSqlArray( [ 'rev_id' => $row['rev_id'] ] ) )
532            ->caller( __METHOD__ )
533            ->execute();
534        $this->removeRelated( $row );
535    }
536
537    /**
538     * Used to locate the index for a query by ObjectLocator::get()
539     * @return string[]
540     */
541    public function getPrimaryKeyColumns() {
542        return [ 'rev_id' ];
543    }
544
545    /**
546     * When retrieving revisions from DB, self::mergeExternalContent will be
547     * called to fetch the content. This could fail, resulting in the content
548     * being a 'false' value.
549     *
550     * @inheritDoc
551     */
552    public function validate( array $row ) {
553        return !isset( $row['rev_content'] ) || $row['rev_content'] !== false;
554    }
555
556    /**
557     * Gets all columns from $row that start with a given prefix and omits other
558     * columns.
559     *
560     * @param array $row Rows to split
561     * @param string $prefix
562     * @return array Remaining rows
563     */
564    protected function splitUpdate( array $row, $prefix = 'rev' ) {
565        $rev = [];
566        foreach ( $row as $key => $value ) {
567            $keyPrefix = strstr( $key, '_', true );
568            if ( $keyPrefix === $prefix ) {
569                $rev[$key] = $value;
570            }
571        }
572        return $rev;
573    }
574}