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