Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
84.48% |
98 / 116 |
|
77.27% |
17 / 22 |
CRAP | |
0.00% |
0 / 1 |
CategoryLinksTable | |
84.48% |
98 / 116 |
|
77.27% |
17 / 22 |
51.23 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
startUpdate | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setParserOutput | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
getTableName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFromField | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getExistingFields | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getNewLinkIDs | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
fetchExistingLinks | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
getExistingLinks | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getSavedTimestamps | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getExistingLinkIDs | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
isExisting | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
isInNewSet | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
insertLink | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
deleteLink | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
needForcedLinkRefresh | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
makePageReferenceValue | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
makeTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
deduplicateLinkIds | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
finishUpdate | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
invalidateCategories | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
updateCategoryCounts | |
58.33% |
14 / 24 |
|
0.00% |
0 / 1 |
12.63 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Deferred\LinksUpdate; |
4 | |
5 | use Collation; |
6 | use MediaWiki\DAO\WikiAwareEntity; |
7 | use MediaWiki\Languages\LanguageConverterFactory; |
8 | use MediaWiki\Page\PageReferenceValue; |
9 | use MediaWiki\Page\WikiPageFactory; |
10 | use MediaWiki\Parser\ParserOutput; |
11 | use MediaWiki\Parser\Sanitizer; |
12 | use MediaWiki\Title\NamespaceInfo; |
13 | use MediaWiki\Title\Title; |
14 | use 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 | */ |
25 | class 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 | } |