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