Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.75% covered (warning)
88.75%
71 / 80
82.76% covered (warning)
82.76%
24 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
LinksTable
88.75% covered (warning)
88.75%
71 / 80
82.76% covered (warning)
82.76%
24 / 29
50.15
0.00% covered (danger)
0.00%
0 / 1
 injectBaseDependencies
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setTransactionTicket
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMoveDetails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setParserOutput
n/a
0 / 0
n/a
0 / 0
0
 getTableName
n/a
0 / 0
n/a
0 / 0
0
 getFromField
n/a
0 / 0
n/a
0 / 0
0
 getExistingFields
n/a
0 / 0
n/a
0 / 0
0
 getNewLinkIDs
n/a
0 / 0
n/a
0 / 0
0
 getExistingLinkIDs
n/a
0 / 0
n/a
0 / 0
0
 isExisting
n/a
0 / 0
n/a
0 / 0
0
 isInNewSet
n/a
0 / 0
n/a
0 / 0
0
 insertLink
n/a
0 / 0
n/a
0 / 0
0
 deleteLink
n/a
0 / 0
n/a
0 / 0
0
 needForcedLinkRefresh
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReplicaDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLBFactory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSourcePageId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSourcePage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMove
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isCrossNamespaceMove
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getMovedPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBatchSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTransactionTicket
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevision
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFromConds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchExistingRows
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 insertRow
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteRow
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 beforeLock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 startUpdate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 finishUpdate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doWrites
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
5.02
 setStrictTestMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInsertOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getLinkIDs
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
21.52
 virtualDomain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Deferred\LinksUpdate;
4
5use InvalidArgumentException;
6use MediaWiki\Linker\LinkTargetLookup;
7use MediaWiki\Page\PageIdentity;
8use MediaWiki\Page\PageReference;
9use MediaWiki\Parser\ParserOutput;
10use MediaWiki\Revision\RevisionRecord;
11use Wikimedia\Rdbms\IDatabase;
12use Wikimedia\Rdbms\IReadableDatabase;
13use Wikimedia\Rdbms\IResultWrapper;
14use Wikimedia\Rdbms\LBFactory;
15
16/**
17 * The base class for classes which update a single link table.
18 *
19 * A LinksTable object is a container for new and existing link sets outbound
20 * from a single page, and an abstraction of the associated DB schema. The
21 * object stores state related to an update of the outbound links of a page.
22 *
23 * Explanation of link ID concept
24 * ------------------------------
25 *
26 * Link IDs identify a link in the new or old state, or in the change arrays.
27 * They are opaque to the base class and are type-hinted here as mixed.
28 *
29 * Conventionally, the link ID is string|string[] and contains the link target
30 * fields.
31 *
32 * The link ID should contain enough information so that the base class can
33 * tell whether an existing link is in the new set, or vice versa, for the
34 * purposes of incremental updates. If a change to a field would cause a DB
35 * update, the field should be in the link ID.
36 *
37 * For example, a change to cl_timestamp does not trigger an update, so
38 * cl_timestamp is not in the link ID.
39 *
40 * @stable to extend
41 * @since 1.38
42 */
43abstract class LinksTable {
44    /** Link type: Inserted (added) links */
45    public const INSERTED = 1;
46
47    /** Link type: Deleted (removed) links */
48    public const DELETED = 2;
49
50    /** Link type: Changed (inserted or removed) links */
51    public const CHANGED = 3;
52
53    /** Link type: existing/old links */
54    public const OLD = 4;
55
56    /** Link type: new links (from the ParserOutput) */
57    public const NEW = 5;
58
59    public const VIRTUAL_DOMAIN = 'virtual-links';
60
61    /**
62     * Rows to delete. An array of associative arrays, each associative array
63     * being the conditions for a delete query. Common conditions should be
64     * leftmost in the associative array so that they can be factored out.
65     *
66     * @var array
67     */
68    protected $rowsToDelete = [];
69
70    /**
71     * Rows to insert. An array of associative arrays, each associative array
72     * mapping field names to values.
73     *
74     * @var array
75     */
76    protected $rowsToInsert = [];
77
78    /** @var array Link IDs for inserted links */
79    protected $insertedLinks = [];
80
81    /** @var array Link IDs for deleted links */
82    protected $deletedLinks = [];
83
84    /** @var LBFactory */
85    private $lbFactory;
86
87    /** @var LinkTargetLookup */
88    protected $linkTargetLookup;
89
90    /** @var IDatabase */
91    private $db;
92
93    /** @var IReadableDatabase */
94    private $replicaDb;
95
96    /** @var PageIdentity */
97    private $sourcePage;
98
99    /** @var PageReference|null */
100    private $movedPage;
101
102    /** @var int */
103    private $batchSize;
104
105    /** @var mixed */
106    private $ticket;
107
108    /** @var RevisionRecord */
109    private $revision;
110
111    /** @var bool */
112    protected $strictTestMode;
113
114    /**
115     * This is called by the factory to inject dependencies for the base class.
116     * This is used instead of the constructor so that changes can be made to
117     * the injected parameters without breaking the subclass constructors.
118     *
119     * @param LBFactory $lbFactory
120     * @param LinkTargetLookup $linkTargetLookup
121     * @param PageIdentity $sourcePage
122     * @param int $batchSize
123     */
124    final public function injectBaseDependencies(
125        LBFactory $lbFactory,
126        LinkTargetLookup $linkTargetLookup,
127        PageIdentity $sourcePage,
128        $batchSize
129    ) {
130        $this->lbFactory = $lbFactory;
131        $this->db = $this->lbFactory->getPrimaryDatabase( $this->virtualDomain() );
132        $this->replicaDb = $this->lbFactory->getReplicaDatabase( $this->virtualDomain() );
133        $this->sourcePage = $sourcePage;
134        $this->batchSize = $batchSize;
135        $this->linkTargetLookup = $linkTargetLookup;
136    }
137
138    /**
139     * Set the empty transaction ticket
140     *
141     * @param mixed $ticket
142     */
143    public function setTransactionTicket( $ticket ) {
144        $this->ticket = $ticket;
145    }
146
147    /**
148     * Set the revision associated with the edit.
149     */
150    public function setRevision( RevisionRecord $revision ) {
151        $this->revision = $revision;
152    }
153
154    /**
155     * Notify the object that the operation is a page move, and set the
156     * original title.
157     */
158    public function setMoveDetails( PageReference $movedPage ) {
159        $this->movedPage = $movedPage;
160    }
161
162    /**
163     * Subclasses should implement this to extract the data they need from the
164     * ParserOutput.
165     *
166     * To support a future refactor of LinksDeletionUpdate, if this method is
167     * not called, the subclass should assume that the new state is empty.
168     */
169    abstract public function setParserOutput( ParserOutput $parserOutput );
170
171    /**
172     * Get the table name.
173     *
174     * @return string
175     */
176    abstract protected function getTableName();
177
178    /**
179     * Get the name of the field which links to page_id.
180     *
181     * @return string
182     */
183    abstract protected function getFromField();
184
185    /**
186     * Get the fields to be used in fetchExistingRows(). Note that
187     * fetchExistingRows() is just a helper for subclasses. The value returned
188     * here is effectively private to the subclass.
189     *
190     * @return array
191     */
192    abstract protected function getExistingFields();
193
194    /**
195     * Get an array (or iterator) of link IDs for the new state.
196     *
197     * See the LinksTable doc comment for an explanation of link IDs.
198     *
199     * @return iterable<mixed>
200     */
201    abstract protected function getNewLinkIDs();
202
203    /**
204     * Get an array (or iterator) of link IDs for the existing state. The
205     * subclass should load the data from the database. There is
206     * fetchExistingRows() to make this easier but the subclass is responsible
207     * for caching.
208     *
209     * See the LinksTable doc comment for an explanation of link IDs.
210     *
211     * @return iterable<mixed>
212     */
213    abstract protected function getExistingLinkIDs();
214
215    /**
216     * Determine whether a link (from the new set) is in the existing set.
217     *
218     * @param mixed $linkId
219     * @return bool
220     */
221    abstract protected function isExisting( $linkId );
222
223    /**
224     * Determine whether a link (from the existing set) is in the new set.
225     *
226     * @param mixed $linkId
227     * @return bool
228     */
229    abstract protected function isInNewSet( $linkId );
230
231    /**
232     * Insert a link identified by ID. The subclass is expected to queue the
233     * insertion by calling insertRow().
234     *
235     * @param mixed $linkId
236     */
237    abstract protected function insertLink( $linkId );
238
239    /**
240     * Delete a link identified by ID. The subclass is expected to queue the
241     * deletion by calling deleteRow().
242     *
243     * @param mixed $linkId
244     */
245    abstract protected function deleteLink( $linkId );
246
247    /**
248     * Subclasses can override this to return true in order to force
249     * reinsertion of all the links due to some property of the link
250     * changing for reasons not represented by the link ID.
251     *
252     * @return bool
253     */
254    protected function needForcedLinkRefresh() {
255        return false;
256    }
257
258    /**
259     * @stable to override
260     * @return IDatabase
261     */
262    protected function getDB(): IDatabase {
263        return $this->db;
264    }
265
266    protected function getReplicaDB(): IReadableDatabase {
267        return $this->replicaDb;
268    }
269
270    protected function getLBFactory(): LBFactory {
271        return $this->lbFactory;
272    }
273
274    /**
275     * Get the page_id of the source page
276     */
277    protected function getSourcePageId(): int {
278        return $this->sourcePage->getId();
279    }
280
281    /**
282     * Get the source page, i.e. the page which is being updated and is the
283     * source of links.
284     */
285    protected function getSourcePage(): PageIdentity {
286        return $this->sourcePage;
287    }
288
289    /**
290     * Determine whether the page was moved
291     *
292     * @return bool
293     */
294    protected function isMove() {
295        return $this->movedPage !== null;
296    }
297
298    /**
299     * Determine whether the page was moved to a different namespace.
300     *
301     * @return bool
302     */
303    protected function isCrossNamespaceMove() {
304        return $this->movedPage !== null
305            && $this->sourcePage->getNamespace() !== $this->movedPage->getNamespace();
306    }
307
308    /**
309     * Assuming the page was moved, get the original page title before the move.
310     * This will throw an exception if the page wasn't moved.
311     */
312    protected function getMovedPage(): PageReference {
313        return $this->movedPage;
314    }
315
316    /**
317     * Get the maximum number of rows to update in a batch.
318     */
319    protected function getBatchSize(): int {
320        return $this->batchSize;
321    }
322
323    /**
324     * Get the empty transaction ticket, or null if there is none.
325     *
326     * @return mixed
327     */
328    protected function getTransactionTicket() {
329        return $this->ticket;
330    }
331
332    /**
333     * Get the RevisionRecord of the new revision, if the LinksUpdate caller
334     * injected one.
335     *
336     * @return RevisionRecord|null
337     */
338    protected function getRevision(): ?RevisionRecord {
339        return $this->revision;
340    }
341
342    /**
343     * Get field=>value associative array for the from field(s)
344     *
345     * @stable to override
346     * @return array
347     */
348    protected function getFromConds() {
349        return [ $this->getFromField() => $this->getSourcePageId() ];
350    }
351
352    /**
353     * Do a select query to fetch the existing rows. This is a helper for
354     * subclasses.
355     */
356    protected function fetchExistingRows(): IResultWrapper {
357        return $this->getReplicaDB()->newSelectQueryBuilder()
358            ->select( $this->getExistingFields() )
359            ->from( $this->getTableName() )
360            ->where( $this->getFromConds() )
361            ->caller( __METHOD__ )
362            ->fetchResultSet();
363    }
364
365    /**
366     * Execute an edit/delete update
367     */
368    final public function update() {
369        $this->startUpdate();
370        $force = $this->needForcedLinkRefresh();
371        foreach ( $this->getNewLinkIDs() as $link ) {
372            if ( $force || !$this->isExisting( $link ) ) {
373                $this->insertLink( $link );
374                $this->insertedLinks[] = $link;
375            }
376        }
377
378        foreach ( $this->getExistingLinkIDs() as $link ) {
379            if ( $force || !$this->isInNewSet( $link ) ) {
380                $this->deleteLink( $link );
381                $this->deletedLinks[] = $link;
382            }
383        }
384        $this->doWrites();
385        $this->finishUpdate();
386    }
387
388    /**
389     * Queue a row for insertion. Subclasses are expected to call this from
390     * insertLink(). The "from" field should not be included in the row.
391     *
392     * @param array $row Associative array mapping fields to values.
393     */
394    protected function insertRow( $row ) {
395        $row += $this->getFromConds();
396        $this->rowsToInsert[] = $row;
397    }
398
399    /**
400     * Queue a deletion operation. Subclasses are expected to call this from
401     * deleteLink(). The "from" field does not need to be included in the
402     * conditions.
403     *
404     * Most often, the conditions match a single row, but this is not required.
405     *
406     * @param array $conds Associative array mapping fields to values,
407     *   specifying the conditions for a delete query.
408     */
409    protected function deleteRow( $conds ) {
410        // Put the "from" field leftmost, so it can be factored out
411        $conds = $this->getFromConds() + $conds;
412        $this->rowsToDelete[] = $conds;
413    }
414
415    /**
416     * Subclasses can override this to do any necessary setup before the lock
417     * is acquired.
418     *
419     * @stable to override
420     */
421    public function beforeLock() {
422    }
423
424    /**
425     * Subclasses can override this to do any necessary setup before individual
426     * write operations begin.
427     *
428     * @stable to override
429     */
430    protected function startUpdate() {
431    }
432
433    /**
434     * Subclasses can override this to do any updates associated with their
435     * link data, for example dispatching HTML update jobs.
436     *
437     * @stable to override
438     */
439    protected function finishUpdate() {
440    }
441
442    /**
443     * Do the common DB operations
444     */
445    protected function doWrites() {
446        $db = $this->getDB();
447        $table = $this->getTableName();
448        $batchSize = $this->getBatchSize();
449        $ticket = $this->getTransactionTicket();
450
451        $deleteBatches = array_chunk( $this->rowsToDelete, $batchSize );
452        foreach ( $deleteBatches as $chunk ) {
453            $db->newDeleteQueryBuilder()
454                ->deleteFrom( $table )
455                ->where( $db->factorConds( $chunk ) )
456                ->caller( __METHOD__ )->execute();
457            if ( count( $deleteBatches ) > 1 ) {
458                $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
459            }
460        }
461
462        $insertBatches = array_chunk( $this->rowsToInsert, $batchSize );
463        foreach ( $insertBatches as $insertBatch ) {
464            $db->newInsertQueryBuilder()
465                ->options( $this->getInsertOptions() )
466                ->insertInto( $table )
467                ->rows( $insertBatch )
468                ->caller( __METHOD__ )->execute();
469            if ( count( $insertBatches ) > 1 ) {
470                $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
471            }
472        }
473    }
474
475    /**
476     * Omit conflict resolution options from the insert query so that testing
477     * can confirm that the incremental update logic was correct.
478     *
479     * @param bool $mode
480     */
481    public function setStrictTestMode( $mode = true ) {
482        $this->strictTestMode = $mode;
483    }
484
485    /**
486     * Get the options for the insert queries
487     *
488     * @return array
489     */
490    protected function getInsertOptions() {
491        if ( $this->strictTestMode ) {
492            return [];
493        } else {
494            return [ 'IGNORE' ];
495        }
496    }
497
498    /**
499     * Get an array or iterator of link IDs of a given type. Some subclasses
500     * use this to provide typed data to callers. This is not public because
501     * link IDs are a private concept.
502     *
503     * @param int $setType One of the class constants: self::INSERTED, self::DELETED,
504     *   self::CHANGED, self::OLD or self::NEW.
505     * @return iterable<mixed>
506     */
507    protected function getLinkIDs( $setType ) {
508        switch ( $setType ) {
509            case self::INSERTED:
510                return $this->insertedLinks;
511
512            case self::DELETED:
513                return $this->deletedLinks;
514
515            case self::CHANGED:
516                return array_merge( $this->insertedLinks, $this->deletedLinks );
517
518            case self::OLD:
519                return $this->getExistingLinkIDs();
520
521            case self::NEW:
522                return $this->getNewLinkIDs();
523
524            default:
525                throw new InvalidArgumentException( __METHOD__ . ": Unknown link type" );
526        }
527    }
528
529    /**
530     * What virtual domain should be used to read/write from the table
531     * @return string|bool
532     */
533    protected function virtualDomain() {
534        return false;
535    }
536}