Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.35% covered (warning)
61.35%
127 / 207
29.63% covered (danger)
29.63%
8 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionStorage
61.35% covered (warning)
61.35%
127 / 207
29.63% covered (danger)
29.63%
8 / 27
399.70
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
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
5.39
 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
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
5.39
 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
1
1<?php
2
3namespace Flow\Data\Storage;
4
5use Flow\Data\ObjectManager;
6use Flow\Data\Utils\Merger;
7use Flow\Data\Utils\ResultDuplicator;
8use Flow\DbFactory;
9use Flow\Exception\DataModelException;
10use Flow\Model\UUID;
11use InvalidArgumentException;
12use MediaWiki\Json\FormatJson;
13use MediaWiki\MediaWikiServices;
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            static function ( array $urls ) {
328                return MediaWikiServices::getInstance()
329                    ->getExternalStoreAccess()
330                    ->fetchFromURLs( $urls );
331            },
332            /* name = */ 'rev_content',
333            /* default = */ ''
334        );
335    }
336
337    protected function buildCompositeInCondition( IReadableDatabase $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 $this->preprocessSqlArray( $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->andExpr( $this->preprocessSqlArray( $query ) );
353            }
354            return $dbr->orExpr( $conditions );
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            $queryBuilder = $dbw->newInsertQueryBuilder()
374                ->insertInto( 'flow_revision' )
375                ->rows( $this->preprocessNestedSqlArray( $revisions ) )
376                ->caller( __METHOD__ );
377            DbStorage::maybeSetInsertIgnore( $queryBuilder );
378            $queryBuilder->execute();
379        }
380
381        return $this->insertRelated( $rows );
382    }
383
384    /**
385     * Checks whether updating content for an existing revision is allowed.
386     * This is only needed for rare actions like fixing XSS.  Normally a new revision
387     * is made.
388     *
389     * Will throw if column configuration is not consistent
390     *
391     * @return bool True if and only if updating existing content is allowed
392     * @throws DataModelException
393     */
394    public function isUpdatingExistingRevisionContentAllowed() {
395        // All of these are required to do a consistent mechanical update.
396        $requiredColumnNames = [
397            'rev_content',
398            'rev_content_length',
399            'rev_flags',
400            'rev_previous_content_length',
401        ];
402
403        // compare required column names against allowedUpdateColumns
404        $diff = array_diff( $requiredColumnNames, $this->allowedUpdateColumns );
405
406        // we're able to update all columns we need: go ahead!
407        if ( !$diff ) {
408            return true;
409        }
410
411        // we're only able to update part of the columns required to update content
412        // @phan-suppress-next-line PhanImpossibleTypeComparison
413        if ( $diff !== $requiredColumnNames ) {
414            throw new DataModelException( "Allowed update column configuration is inconsistent",
415                'allowed-update-inconsistent' );
416        }
417
418        // content changes aren't allowed
419        return false;
420    }
421
422    /**
423     * If this is a new row (new rows should always have content) or part of an update
424     * involving a content change, inserts into external store.
425     * @param array $row
426     * @return array
427     */
428    protected function processExternalStore( array $row ) {
429        // Check if we need to insert new content
430        if (
431            $this->externalStore &&
432            isset( $row['rev_content'] )
433        ) {
434            $row = $this->insertExternalStore( $row );
435        }
436
437        // If a content url is available store that in the db
438        // instead of real content.
439        if ( isset( $row['rev_content_url'] ) ) {
440            $row['rev_content'] = $row['rev_content_url'];
441        }
442        unset( $row['rev_content_url'] );
443
444        return $row;
445    }
446
447    protected function insertExternalStore( array $row ) {
448        if ( $row['rev_content'] === null ) {
449            throw new DataModelException( "Must have data to write to external storage", 'process-data' );
450        }
451        $url = MediaWikiServices::getInstance()
452            ->getExternalStoreAccess()
453            ->insert( $row['rev_content'], [], $this->externalStore );
454        if ( !$url ) {
455            throw new DataModelException( "Unable to store text to external storage", 'process-data' );
456        }
457        $row['rev_content_url'] = $url;
458        if ( isset( $row['rev_flags'] ) && $row['rev_flags'] ) {
459            $row['rev_flags'] .= ',external';
460        } else {
461            $row['rev_flags'] = 'external';
462        }
463
464        return $row;
465    }
466
467    /**
468     * Gets the required updates.  Any changes to External Store will be reflected in
469     * the returned array.
470     *
471     * @param array $old Associative array mapping prior columns to old values
472     * @param array $new Associative array mapping updated columns to new values
473     *
474     * @return array Validated change set as associative array, mapping columns to
475     *   change to their new values
476     */
477    public function calcUpdates( array $old, array $new ) {
478        // First, see if there are any changes to content at all.
479        // If not, processExternalStore will know not to insert a useless row for
480        // unchanged content (if updating content is allowed).
481        $unvalidatedChangeset = ObjectManager::calcUpdatesWithoutValidation( $old, $new );
482
483        // We check here so if it's not allowed, we don't insert a wasted External
484        // Store entry, then throw an exception in the parent calcUpdates.
485        if ( $this->isUpdatingExistingRevisionContentAllowed() ) {
486            $unvalidatedChangeset = $this->processExternalStore( $unvalidatedChangeset );
487        }
488
489        // The parent calcUpdates does the validation that we're not changing a non-allowed
490        // field, regardless of whether explicitly passed in, or done by processExternalStore.
491        $validatedChangeset = parent::calcUpdates( [], $unvalidatedChangeset );
492        return $validatedChangeset;
493    }
494
495    /**
496     * This is to *UPDATE* a revision.  It should hardly ever be used.
497     * For the most part should insert a new revision.  This should only be called
498     * by maintenance scripts and (future) suppression features.
499     * It supports updating content, which is only intended for required mechanical
500     * transformations, such as XSS fixes.  However, since this is only intended for
501     * maintenance scripts, these columns must first be temporarily added to
502     * allowedUpdateColumns.
503     * @param array $old
504     * @param array $new
505     * @return bool
506     */
507    public function update( array $old, array $new ) {
508        $changeSet = $this->calcUpdates( $old, $new );
509
510        $rev = $this->splitUpdate( $changeSet, 'rev' );
511
512        if ( $rev ) {
513            $dbw = $this->dbFactory->getDB( DB_PRIMARY );
514            $dbw->newUpdateQueryBuilder()
515                ->update( 'flow_revision' )
516                ->set( $this->preprocessSqlArray( $rev ) )
517                ->where( $this->preprocessSqlArray( [ 'rev_id' => $old['rev_id'] ] ) )
518                ->caller( __METHOD__ )
519                ->execute();
520            if ( !$dbw->affectedRows() ) {
521                return false;
522            }
523        }
524        return (bool)$this->updateRelated( $changeSet, $old );
525    }
526
527    /**
528     * Revisions can only be removed for LIMITED circumstances,  in almost all cases
529     * the offending revision should be updated with appropriate suppression.
530     * Also note this doesnt delete the whole post, it just deletes the revision.
531     * The post will *always* exist in the tree structure, it will just show up as
532     * [deleted] or something
533     */
534    public function remove( array $row ) {
535        $this->dbFactory->getDB( DB_PRIMARY )->newDeleteQueryBuilder()
536            ->deleteFrom( 'flow_revision' )
537            ->where( $this->preprocessSqlArray( [ 'rev_id' => $row['rev_id'] ] ) )
538            ->caller( __METHOD__ )
539            ->execute();
540        $this->removeRelated( $row );
541    }
542
543    /**
544     * Used to locate the index for a query by ObjectLocator::get()
545     * @return string[]
546     */
547    public function getPrimaryKeyColumns() {
548        return [ 'rev_id' ];
549    }
550
551    /**
552     * When retrieving revisions from DB, self::mergeExternalContent will be
553     * called to fetch the content. This could fail, resulting in the content
554     * being a 'false' value.
555     *
556     * @inheritDoc
557     */
558    public function validate( array $row ) {
559        return !isset( $row['rev_content'] ) || $row['rev_content'] !== false;
560    }
561
562    /**
563     * Gets all columns from $row that start with a given prefix and omits other
564     * columns.
565     *
566     * @param array<string,mixed> $row
567     * @param string $prefix Defaults to "rev"
568     * @return array<string,mixed>
569     */
570    protected function splitUpdate( array $row, string $prefix = 'rev' ): array {
571        $prefix .= '_';
572        return array_filter(
573            $row,
574            static fn ( string $key ) => str_starts_with( $key, $prefix ),
575            ARRAY_FILTER_USE_KEY
576        );
577    }
578
579}