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