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\Language\ILanguageConverter; |
8 | use MediaWiki\Languages\LanguageConverterFactory; |
9 | use MediaWiki\Page\PageReferenceValue; |
10 | use MediaWiki\Page\WikiPageFactory; |
11 | use MediaWiki\Parser\ParserOutput; |
12 | use MediaWiki\Parser\Sanitizer; |
13 | use MediaWiki\Title\NamespaceInfo; |
14 | use MediaWiki\Title\Title; |
15 | use 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 | */ |
26 | class 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 | } |