Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.57% covered (success)
94.57%
122 / 129
86.96% covered (warning)
86.96%
20 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryLinksTable
94.57% covered (success)
94.57%
122 / 129
86.96% covered (warning)
86.96%
20 / 23
42.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
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%
20 / 20
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
 getTargetIdField
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%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 deleteLink
100.00% covered (success)
100.00%
6 / 6
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deduplicateLinkIds
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 finishUpdate
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 invalidateCategories
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 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 MediaWiki\Category\Category;
6use MediaWiki\Collation\Collation;
7use MediaWiki\Config\Config;
8use MediaWiki\HookContainer\HookContainer;
9use MediaWiki\HookContainer\HookRunner;
10use MediaWiki\JobQueue\JobQueueGroup;
11use MediaWiki\JobQueue\Jobs\CategoryCountUpdateJob;
12use MediaWiki\JobQueue\Utils\PurgeJobUtils;
13use MediaWiki\Language\ILanguageConverter;
14use MediaWiki\Language\LanguageConverterFactory;
15use MediaWiki\Logger\LoggerFactory;
16use MediaWiki\Page\PageReferenceValue;
17use MediaWiki\Page\WikiPageFactory;
18use MediaWiki\Parser\ParserOutput;
19use MediaWiki\Parser\ParserOutputLinkTypes;
20use MediaWiki\Parser\Sanitizer;
21use MediaWiki\Storage\NameTableStore;
22use MediaWiki\Title\NamespaceInfo;
23use MediaWiki\Title\Title;
24use Wikimedia\ObjectCache\WANObjectCache;
25use Wikimedia\Rdbms\ILoadBalancer;
26
27/**
28 * categorylinks
29 *
30 * Link ID format: string[]
31 *   - 0: Category name
32 *   - 1: User-specified sort key (cl_sortkey_prefix)
33 *
34 * @since 1.38
35 */
36class CategoryLinksTable extends TitleLinksTable {
37    /**
38     * @var array Associative array of new links, with the category name in the
39     *   key. The value is a list consisting of the sort key prefix and the sort
40     *   key.
41     */
42    private $newLinks = [];
43
44    /**
45     * @var array|null Associative array of existing links, or null if it has
46     *   not been loaded yet
47     */
48    private $existingLinks;
49
50    /**
51     * @var array Associative array of saved timestamps, if there is a force
52     *   refresh due to a page move
53     */
54    private $savedTimestamps = null;
55
56    /** @var ILanguageConverter */
57    private $languageConverter;
58
59    /** @var Collation */
60    private $collation;
61
62    /** @var string The collation name for cl_collation */
63    private $collationName;
64
65    /** @var string The table name */
66    private $tableName = 'categorylinks';
67
68    /** @var bool */
69    private $isTempTable;
70
71    /** @var string The category type, which depends on the source page */
72    private $categoryType;
73
74    /** @var NamespaceInfo */
75    private $namespaceInfo;
76
77    /** @var WikiPageFactory */
78    private $wikiPageFactory;
79
80    private NameTableStore $collationNameStore;
81    private JobQueueGroup $jobQueueGroup;
82    private HookRunner $hookRunner;
83
84    /**
85     * @param LanguageConverterFactory $converterFactory
86     * @param NamespaceInfo $namespaceInfo
87     * @param WikiPageFactory $wikiPageFactory
88     * @param ILoadBalancer $loadBalancer
89     * @param WANObjectCache $WANObjectCache
90     * @param Config $config
91     * @param JobqueueGroup $jobQueueGroup
92     * @param HookContainer $hookContainer
93     * @param Collation $collation
94     * @param string $collationName
95     * @param string $tableName
96     * @param bool $isTempTable
97     */
98    public function __construct(
99        LanguageConverterFactory $converterFactory,
100        NamespaceInfo $namespaceInfo,
101        WikiPageFactory $wikiPageFactory,
102        ILoadBalancer $loadBalancer,
103        WANObjectCache $WANObjectCache,
104        Config $config,
105        JobqueueGroup $jobQueueGroup,
106        HookContainer $hookContainer,
107        Collation $collation,
108        $collationName,
109        $tableName,
110        $isTempTable
111    ) {
112        $this->languageConverter = $converterFactory->getLanguageConverter();
113        $this->namespaceInfo = $namespaceInfo;
114        $this->wikiPageFactory = $wikiPageFactory;
115        $this->collation = $collation;
116        $this->jobQueueGroup = $jobQueueGroup;
117        $this->hookRunner = new HookRunner( $hookContainer );
118        $this->collationName = $collationName;
119        $this->tableName = $tableName;
120        $this->isTempTable = $isTempTable;
121
122        $this->collationNameStore = new NameTableStore(
123            $loadBalancer,
124            $WANObjectCache,
125            LoggerFactory::getInstance( 'SecondaryDataUpdate' ),
126            'collation',
127            'collation_id',
128            'collation_name'
129        );
130    }
131
132    /**
133     * Cache the category type after the source page has been set
134     */
135    public function startUpdate() {
136        $this->categoryType = $this->namespaceInfo
137            ->getCategoryLinkType( $this->getSourcePage()->getNamespace() );
138    }
139
140    public function setParserOutput( ParserOutput $parserOutput ) {
141        $this->newLinks = [];
142        $sourceTitle = Title::castFromPageIdentity( $this->getSourcePage() );
143        $sortKeyInputs = [];
144        foreach (
145            $parserOutput->getLinkList( ParserOutputLinkTypes::CATEGORY )
146            as [ 'link' => $targetTitle, 'sort' => $sortKey ]
147        ) {
148            '@phan-var string $sortKey'; // sort key will never be null
149
150            if ( $sortKey == '' ) {
151                $sortKey = $parserOutput->getPageProperty( "defaultsort" ) ?? '';
152            }
153            $sortKey = $this->languageConverter->convertCategoryKey( $sortKey );
154
155            // Clean up the sort key, regardless of source
156            $sortKey = Sanitizer::decodeCharReferences( $sortKey );
157            $sortKey = str_replace( "\n", '', $sortKey );
158
159            // If the sort key is longer then 255 bytes, it is truncated by DB,
160            // and then doesn't match when comparing existing vs current
161            // categories, causing T27254.
162            $sortKeyPrefix = mb_strcut( $sortKey, 0, 255 );
163
164            $name = $targetTitle->getDBkey();
165            $targetTitle = Title::castFromLinkTarget( $targetTitle );
166            $this->languageConverter->findVariantLink( $name, $targetTitle, true );
167            // Ignore the returned text, DB key should be used for links (T328477).
168            $name = $targetTitle->getDBkey();
169
170            // Treat custom sort keys as a prefix, so that if multiple
171            // things are forced to sort as '*' or something, they'll
172            // sort properly in the category rather than in page_id
173            // order or such.
174            $sortKeyInputs[$name] = $sourceTitle->getCategorySortkey( $sortKeyPrefix );
175            $this->newLinks[$name] = [ $sortKeyPrefix ];
176        }
177        $sortKeys = $this->collation->getSortKeys( $sortKeyInputs );
178        foreach ( $sortKeys as $name => $sortKey ) {
179            $this->newLinks[$name][1] = $sortKey;
180        }
181    }
182
183    /** @inheritDoc */
184    protected function getTableName() {
185        return $this->tableName;
186    }
187
188    /** @inheritDoc */
189    protected function getFromField() {
190        return 'cl_from';
191    }
192
193    /** @inheritDoc */
194    protected function getTargetIdField() {
195        return 'cl_target_id';
196    }
197
198    /** @inheritDoc */
199    protected function getExistingFields() {
200        $fields = [ 'lt_title', 'cl_sortkey_prefix' ];
201
202        if ( $this->needForcedLinkRefresh() ) {
203            $fields[] = 'cl_timestamp';
204        }
205
206        return $fields;
207    }
208
209    /**
210     * Get the new link IDs. The link ID is a list with the name in the first
211     * element and the sort key prefix in the second element.
212     *
213     * @return iterable<array>
214     */
215    protected function getNewLinkIDs() {
216        foreach ( $this->newLinks as $name => [ $prefix, ] ) {
217            yield [ (string)$name, $prefix ];
218        }
219    }
220
221    /**
222     * Get the existing links from the database
223     */
224    private function fetchExistingLinks() {
225        $this->existingLinks = [];
226        $this->savedTimestamps = [];
227        $force = $this->needForcedLinkRefresh();
228        foreach ( $this->fetchExistingRows() as $row ) {
229            $this->existingLinks[$row->lt_title] = $row->cl_sortkey_prefix;
230            if ( $force ) {
231                $this->savedTimestamps[$row->lt_title] = $row->cl_timestamp;
232            }
233        }
234    }
235
236    /**
237     * Get the existing links as an associative array, with the category name
238     * in the key and the sort key prefix in the value.
239     *
240     * @return array
241     */
242    private function getExistingLinks() {
243        if ( $this->existingLinks === null ) {
244            $this->fetchExistingLinks();
245        }
246        return $this->existingLinks;
247    }
248
249    private function getSavedTimestamps(): array {
250        if ( $this->savedTimestamps === null ) {
251            $this->fetchExistingLinks();
252        }
253        return $this->savedTimestamps;
254    }
255
256    /**
257     * @return \Generator
258     */
259    protected function getExistingLinkIDs() {
260        foreach ( $this->getExistingLinks() as $name => $sortkey ) {
261            yield [ (string)$name, $sortkey ];
262        }
263    }
264
265    /** @inheritDoc */
266    protected function isExisting( $linkId ) {
267        $links = $this->getExistingLinks();
268        [ $name, $prefix ] = $linkId;
269        return \array_key_exists( $name, $links ) && $links[$name] === $prefix;
270    }
271
272    /** @inheritDoc */
273    protected function isInNewSet( $linkId ) {
274        [ $name, $prefix ] = $linkId;
275        return \array_key_exists( $name, $this->newLinks )
276            && $this->newLinks[$name][0] === $prefix;
277    }
278
279    /** @inheritDoc */
280    protected function insertLink( $linkId ) {
281        [ $name, $prefix ] = $linkId;
282        $sortKey = $this->newLinks[$name][1];
283        $savedTimestamps = $this->getSavedTimestamps();
284
285        // Preserve cl_timestamp in the case of a forced refresh
286        $timestamp = $this->getDB()->timestamp( $savedTimestamps[$name] ?? 0 );
287
288        $targetFields = [];
289        $targetFields['cl_target_id'] = $this->linkTargetLookup->acquireLinkTargetId(
290            $this->makeTitle( $linkId ),
291            $this->getDB()
292        );
293        $targetFields['cl_collation_id'] = $this->collationNameStore->acquireId( $this->collationName );
294
295        $this->insertRow( $targetFields + [
296            'cl_sortkey' => $sortKey,
297            'cl_timestamp' => $timestamp,
298            'cl_sortkey_prefix' => $prefix,
299            'cl_type' => $this->categoryType,
300        ] );
301    }
302
303    /** @inheritDoc */
304    protected function deleteLink( $linkId ) {
305        $this->deleteRow( [
306            'cl_target_id' => $this->linkTargetLookup->acquireLinkTargetId(
307                $this->makeTitle( $linkId ),
308                $this->getDB()
309            )
310        ] );
311    }
312
313    /** @inheritDoc */
314    protected function needForcedLinkRefresh() {
315        // cl_sortkey and possibly cl_type will change if it is a page move
316        return $this->isMove();
317    }
318
319    /** @inheritDoc */
320    protected function makePageReferenceValue( $linkId ): PageReferenceValue {
321        return PageReferenceValue::localReference( NS_CATEGORY, $linkId[0] );
322    }
323
324    /** @inheritDoc */
325    protected function makeTitle( $linkId ): Title {
326        return Title::makeTitle( NS_CATEGORY, $linkId[0] );
327    }
328
329    /** @inheritDoc */
330    protected function deduplicateLinkIds( $linkIds ) {
331        $seen = [];
332        foreach ( $linkIds as $linkId ) {
333            if ( !\array_key_exists( $linkId[0], $seen ) ) {
334                $seen[$linkId[0]] = true;
335                yield $linkId;
336            }
337        }
338    }
339
340    protected function finishUpdate() {
341        if ( $this->isTempTable ) {
342            // Don't do invalidations for temporary collations
343            return;
344        }
345
346        // A update of sortkey on move is detected as insert + delete,
347        // but the categories does not need to update the counters or invalidate caches
348        $allInsertedLinks = array_column( $this->insertedLinks, 0 );
349        $allDeletedLinks = array_column( $this->deletedLinks, 0 );
350        $insertedLinks = array_diff( $allInsertedLinks, $allDeletedLinks );
351        $deletedLinks = array_diff( $allDeletedLinks, $allInsertedLinks );
352
353        $this->invalidateCategories( $insertedLinks, $deletedLinks );
354        if ( $insertedLinks || $deletedLinks ) {
355            $this->jobQueueGroup->lazyPush(
356                CategoryCountUpdateJob::newSpec(
357                    $this->getSourcePage(),
358                    $insertedLinks,
359                    $deletedLinks,
360                    $this->getBatchSize()
361                )
362            );
363        }
364
365        $wp = $this->wikiPageFactory->newFromTitle( $this->getSourcePage() );
366
367        foreach ( $insertedLinks as $catName ) {
368            $cat = Category::newFromName( $catName );
369            $this->hookRunner->onCategoryAfterPageAdded( $cat, $wp );
370        }
371
372        foreach ( $deletedLinks as $catName ) {
373            $cat = Category::newFromName( $catName );
374            $this->hookRunner->onCategoryAfterPageRemoved( $cat, $wp, $this->getSourcePage()->getId() );
375        }
376    }
377
378    private function invalidateCategories( array $insertedLinks, array $deletedLinks ) {
379        $changedCategoryNames = array_merge(
380            $insertedLinks,
381            $deletedLinks
382        );
383        PurgeJobUtils::invalidatePages(
384            $this->getDB(), NS_CATEGORY, $changedCategoryNames );
385    }
386
387    /** @inheritDoc */
388    protected function virtualDomain(): string {
389        return self::VIRTUAL_DOMAIN;
390    }
391}