Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.48% covered (warning)
84.48%
98 / 116
77.27% covered (warning)
77.27%
17 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryLinksTable
84.48% covered (warning)
84.48%
98 / 116
77.27% covered (warning)
77.27%
17 / 22
51.23
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 startUpdate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setParserOutput
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getTableName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFromField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExistingFields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getNewLinkIDs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 fetchExistingLinks
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getExistingLinks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSavedTimestamps
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getExistingLinkIDs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isExisting
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isInNewSet
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 insertLink
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 deleteLink
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needForcedLinkRefresh
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makePageReferenceValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deduplicateLinkIds
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 finishUpdate
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 invalidateCategories
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 updateCategoryCounts
58.33% covered (warning)
58.33%
14 / 24
0.00% covered (danger)
0.00%
0 / 1
12.63
1<?php
2
3namespace MediaWiki\Deferred\LinksUpdate;
4
5use Collation;
6use MediaWiki\DAO\WikiAwareEntity;
7use MediaWiki\Languages\LanguageConverterFactory;
8use MediaWiki\Page\PageReferenceValue;
9use MediaWiki\Page\WikiPageFactory;
10use MediaWiki\Parser\ParserOutput;
11use MediaWiki\Parser\Sanitizer;
12use MediaWiki\Title\NamespaceInfo;
13use MediaWiki\Title\Title;
14use PurgeJobUtils;
15
16/**
17 * categorylinks
18 *
19 * Link ID format: string[]
20 *   - 0: Category name
21 *   - 1: User-specified sort key (cl_sortkey_prefix)
22 *
23 * @since 1.38
24 */
25class CategoryLinksTable extends TitleLinksTable {
26    /**
27     * @var array Associative array of new links, with the category name in the
28     *   key. The value is a list consisting of the sort key prefix and the sort
29     *   key.
30     */
31    private $newLinks = [];
32
33    /**
34     * @var array|null Associative array of existing links, or null if it has
35     *   not been loaded yet
36     */
37    private $existingLinks;
38
39    /**
40     * @var array Associative array of saved timestamps, if there is a force
41     *   refresh due to a page move
42     */
43    private $savedTimestamps = null;
44
45    /** @var \ILanguageConverter */
46    private $languageConverter;
47
48    /** @var \Collation */
49    private $collation;
50
51    /** @var string The collation name for cl_collation */
52    private $collationName;
53
54    /** @var string The table name */
55    private $tableName = 'categorylinks';
56
57    /** @var bool */
58    private $isTempTable;
59
60    /** @var string The category type, which depends on the source page */
61    private $categoryType;
62
63    /** @var NamespaceInfo */
64    private $namespaceInfo;
65
66    /** @var WikiPageFactory */
67    private $wikiPageFactory;
68
69    /**
70     * @param LanguageConverterFactory $converterFactory
71     * @param NamespaceInfo $namespaceInfo
72     * @param WikiPageFactory $wikiPageFactory
73     * @param Collation $collation
74     * @param string $collationName
75     * @param string $tableName
76     * @param bool $isTempTable
77     */
78    public function __construct(
79        LanguageConverterFactory $converterFactory,
80        NamespaceInfo $namespaceInfo,
81        WikiPageFactory $wikiPageFactory,
82        Collation $collation,
83        $collationName,
84        $tableName,
85        $isTempTable
86    ) {
87        $this->languageConverter = $converterFactory->getLanguageConverter();
88        $this->namespaceInfo = $namespaceInfo;
89        $this->wikiPageFactory = $wikiPageFactory;
90        $this->collation = $collation;
91        $this->collationName = $collationName;
92        $this->tableName = $tableName;
93        $this->isTempTable = $isTempTable;
94    }
95
96    /**
97     * Cache the category type after the source page has been set
98     */
99    public function startUpdate() {
100        $this->categoryType = $this->namespaceInfo
101            ->getCategoryLinkType( $this->getSourcePage()->getNamespace() );
102    }
103
104    public function setParserOutput( ParserOutput $parserOutput ) {
105        $this->newLinks = [];
106        $sourceTitle = Title::castFromPageIdentity( $this->getSourcePage() );
107        $sortKeyInputs = [];
108        foreach ( $parserOutput->getCategoryNames() as $name ) {
109            $sortKey = $parserOutput->getCategorySortKey( $name );
110            '@phan-var string $sortKey'; // sort key will never be null
111
112            if ( $sortKey == '' ) {
113                $sortKey = $parserOutput->getPageProperty( "defaultsort" ) ?? '';
114            }
115            $sortKey = $this->languageConverter->convertCategoryKey( $sortKey );
116
117            // Clean up the sort key, regardless of source
118            $sortKey = Sanitizer::decodeCharReferences( $sortKey );
119            $sortKey = str_replace( "\n", '', $sortKey );
120
121            // If the sort key is longer then 255 bytes, it is truncated by DB,
122            // and then doesn't match when comparing existing vs current
123            // categories, causing T27254.
124            $sortKeyPrefix = mb_strcut( $sortKey, 0, 255 );
125
126            $targetTitle = Title::makeTitle( NS_CATEGORY, $name );
127            $this->languageConverter->findVariantLink( $name, $targetTitle, true );
128            // Ignore the returned text, DB key should be used for links (T328477).
129            $name = $targetTitle->getDBKey();
130
131            // Treat custom sort keys as a prefix, so that if multiple
132            // things are forced to sort as '*' or something, they'll
133            // sort properly in the category rather than in page_id
134            // order or such.
135            $sortKeyInputs[$name] = $sourceTitle->getCategorySortkey( $sortKeyPrefix );
136            $this->newLinks[$name] = [ $sortKeyPrefix ];
137        }
138        $sortKeys = $this->collation->getSortKeys( $sortKeyInputs );
139        foreach ( $sortKeys as $name => $sortKey ) {
140            $this->newLinks[$name][1] = $sortKey;
141        }
142    }
143
144    protected function getTableName() {
145        return $this->tableName;
146    }
147
148    protected function getFromField() {
149        return 'cl_from';
150    }
151
152    protected function getExistingFields() {
153        $fields = [ 'cl_to', 'cl_sortkey_prefix' ];
154        if ( $this->needForcedLinkRefresh() ) {
155            $fields[] = 'cl_timestamp';
156        }
157        return $fields;
158    }
159
160    /**
161     * Get the new link IDs. The link ID is a list with the name in the first
162     * element and the sort key prefix in the second element.
163     *
164     * @return iterable<array>
165     */
166    protected function getNewLinkIDs() {
167        foreach ( $this->newLinks as $name => [ $prefix, ] ) {
168            yield [ (string)$name, $prefix ];
169        }
170    }
171
172    /**
173     * Get the existing links from the database
174     */
175    private function fetchExistingLinks() {
176        $this->existingLinks = [];
177        $this->savedTimestamps = [];
178        $force = $this->needForcedLinkRefresh();
179        foreach ( $this->fetchExistingRows() as $row ) {
180            $this->existingLinks[$row->cl_to] = $row->cl_sortkey_prefix;
181            if ( $force ) {
182                $this->savedTimestamps[$row->cl_to] = $row->cl_timestamp;
183            }
184        }
185    }
186
187    /**
188     * Get the existing links as an associative array, with the category name
189     * in the key and the sort key prefix in the value.
190     *
191     * @return array
192     */
193    private function getExistingLinks() {
194        if ( $this->existingLinks === null ) {
195            $this->fetchExistingLinks();
196        }
197        return $this->existingLinks;
198    }
199
200    private function getSavedTimestamps() {
201        if ( $this->savedTimestamps === null ) {
202            $this->fetchExistingLinks();
203        }
204        return $this->savedTimestamps;
205    }
206
207    /**
208     * @return \Generator
209     */
210    protected function getExistingLinkIDs() {
211        foreach ( $this->getExistingLinks() as $name => $sortkey ) {
212            yield [ (string)$name, $sortkey ];
213        }
214    }
215
216    protected function isExisting( $linkId ) {
217        $links = $this->getExistingLinks();
218        [ $name, $prefix ] = $linkId;
219        return \array_key_exists( $name, $links ) && $links[$name] === $prefix;
220    }
221
222    protected function isInNewSet( $linkId ) {
223        [ $name, $prefix ] = $linkId;
224        return \array_key_exists( $name, $this->newLinks )
225            && $this->newLinks[$name][0] === $prefix;
226    }
227
228    protected function insertLink( $linkId ) {
229        [ $name, $prefix ] = $linkId;
230        $sortKey = $this->newLinks[$name][1];
231        $savedTimestamps = $this->getSavedTimestamps();
232
233        // Preserve cl_timestamp in the case of a forced refresh
234        $timestamp = $this->getDB()->timestamp( $savedTimestamps[$name] ?? 0 );
235
236        $this->insertRow( [
237            'cl_to' => $name,
238            'cl_sortkey' => $sortKey,
239            'cl_timestamp' => $timestamp,
240            'cl_sortkey_prefix' => $prefix,
241            'cl_collation' => $this->collationName,
242            'cl_type' => $this->categoryType,
243        ] );
244    }
245
246    protected function deleteLink( $linkId ) {
247        $this->deleteRow( [ 'cl_to' => $linkId[0] ] );
248    }
249
250    protected function needForcedLinkRefresh() {
251        // cl_sortkey and possibly cl_type will change if it is a page move
252        return $this->isMove();
253    }
254
255    protected function makePageReferenceValue( $linkId ): PageReferenceValue {
256        return new PageReferenceValue( NS_CATEGORY, $linkId[0], WikiAwareEntity::LOCAL );
257    }
258
259    protected function makeTitle( $linkId ): Title {
260        return Title::makeTitle( NS_CATEGORY, $linkId[0] );
261    }
262
263    protected function deduplicateLinkIds( $linkIds ) {
264        $seen = [];
265        foreach ( $linkIds as $linkId ) {
266            if ( !\array_key_exists( $linkId[0], $seen ) ) {
267                $seen[$linkId[0]] = true;
268                yield $linkId;
269            }
270        }
271    }
272
273    protected function finishUpdate() {
274        if ( $this->isTempTable ) {
275            // Don't do invalidations for temporary collations
276            return;
277        }
278
279        // A update of sortkey on move is detected as insert + delete,
280        // but the categories does not need to update the counters or invalidate caches
281        $allInsertedLinks = array_column( $this->insertedLinks, 0 );
282        $allDeletedLinks = array_column( $this->deletedLinks, 0 );
283        $insertedLinks = array_diff( $allInsertedLinks, $allDeletedLinks );
284        $deletedLinks = array_diff( $allDeletedLinks, $allInsertedLinks );
285
286        $this->invalidateCategories( $insertedLinks, $deletedLinks );
287        $this->updateCategoryCounts( $insertedLinks, $deletedLinks );
288    }
289
290    private function invalidateCategories( array $insertedLinks, array $deletedLinks ) {
291        $changedCategoryNames = array_merge(
292            $insertedLinks,
293            $deletedLinks
294        );
295        PurgeJobUtils::invalidatePages(
296            $this->getDB(), NS_CATEGORY, $changedCategoryNames );
297    }
298
299    /**
300     * Update all the appropriate counts in the category table.
301     * @param array $insertedLinks
302     * @param array $deletedLinks
303     */
304    private function updateCategoryCounts( array $insertedLinks, array $deletedLinks ) {
305        if ( !$insertedLinks && !$deletedLinks ) {
306            return;
307        }
308
309        $domainId = $this->getDB()->getDomainID();
310        $wp = $this->wikiPageFactory->newFromTitle( $this->getSourcePage() );
311        $lbf = $this->getLBFactory();
312        $size = $this->getBatchSize();
313        // T163801: try to release any row locks to reduce contention
314        $lbf->commitAndWaitForReplication( __METHOD__, $this->getTransactionTicket() );
315
316        if ( count( $insertedLinks ) + count( $deletedLinks ) < $size ) {
317            $wp->updateCategoryCounts(
318                $insertedLinks,
319                $deletedLinks,
320                $this->getSourcePageId()
321            );
322            $lbf->commitAndWaitForReplication( __METHOD__, $this->getTransactionTicket() );
323        } else {
324            $addedChunks = array_chunk( $insertedLinks, $size );
325            foreach ( $addedChunks as $chunk ) {
326                $wp->updateCategoryCounts( $chunk, [], $this->getSourcePageId() );
327                if ( count( $addedChunks ) > 1 ) {
328                    $lbf->commitAndWaitForReplication( __METHOD__, $this->getTransactionTicket() );
329                }
330            }
331
332            $deletedChunks = array_chunk( $deletedLinks, $size );
333            foreach ( $deletedChunks as $chunk ) {
334                $wp->updateCategoryCounts( [], $chunk, $this->getSourcePageId() );
335                if ( count( $deletedChunks ) > 1 ) {
336                    $lbf->commitAndWaitForReplication( __METHOD__, $this->getTransactionTicket() );
337                }
338            }
339
340        }
341    }
342}