Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.11% |
410 / 450 |
|
46.15% |
6 / 13 |
CRAP | |
0.00% |
0 / 1 |
ThreadItemStore | |
91.11% |
410 / 450 |
|
46.15% |
6 / 13 |
74.54 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
isDisabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
findNewestRevisionsByName | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
5.00 | |||
findNewestRevisionsById | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
5 | |||
findNewestRevisionsByHeading | |
95.92% |
47 / 49 |
|
0.00% |
0 / 1 |
5 | |||
findNewestRevisionsByQuery | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
5 | |||
fetchItemsResultSet | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
1 | |||
fetchRevisionAndPageForItems | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
getThreadItemFromRow | |
88.10% |
37 / 42 |
|
0.00% |
0 / 1 |
11.20 | |||
findThreadItemsInCurrentRevision | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
20 | |||
getIdsNamesBuilder | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
findOrInsertId | |
38.89% |
7 / 18 |
|
0.00% |
0 / 1 |
7.65 | |||
insertThreadItems | |
98.03% |
199 / 203 |
|
0.00% |
0 / 1 |
24 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\DiscussionTools; |
4 | |
5 | use Exception; |
6 | use Language; |
7 | use MediaWiki\Config\Config; |
8 | use MediaWiki\Config\ConfigFactory; |
9 | use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem; |
10 | use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseCommentItem; |
11 | use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseHeadingItem; |
12 | use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem; |
13 | use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem; |
14 | use MediaWiki\Page\PageStore; |
15 | use MediaWiki\Revision\RevisionRecord; |
16 | use MediaWiki\Revision\RevisionStore; |
17 | use MediaWiki\Title\TitleFormatter; |
18 | use MediaWiki\Title\TitleValue; |
19 | use MediaWiki\User\ActorStore; |
20 | use MediaWiki\Utils\MWTimestamp; |
21 | use stdClass; |
22 | use Wikimedia\NormalizedException\NormalizedException; |
23 | use Wikimedia\Rdbms\DBError; |
24 | use Wikimedia\Rdbms\IDatabase; |
25 | use Wikimedia\Rdbms\IExpression; |
26 | use Wikimedia\Rdbms\ILBFactory; |
27 | use Wikimedia\Rdbms\ILoadBalancer; |
28 | use Wikimedia\Rdbms\IReadableDatabase; |
29 | use Wikimedia\Rdbms\IResultWrapper; |
30 | use Wikimedia\Rdbms\LikeValue; |
31 | use Wikimedia\Rdbms\ReadOnlyMode; |
32 | use Wikimedia\Rdbms\SelectQueryBuilder; |
33 | use Wikimedia\Timestamp\TimestampException; |
34 | |
35 | /** |
36 | * Stores and fetches ThreadItemSets from the database. |
37 | */ |
38 | class ThreadItemStore { |
39 | |
40 | private Config $config; |
41 | private ILBFactory $dbProvider; |
42 | private ReadOnlyMode $readOnlyMode; |
43 | private PageStore $pageStore; |
44 | private RevisionStore $revStore; |
45 | private TitleFormatter $titleFormatter; |
46 | private ActorStore $actorStore; |
47 | private Language $language; |
48 | |
49 | public function __construct( |
50 | ConfigFactory $configFactory, |
51 | ILBFactory $dbProvider, |
52 | ReadOnlyMode $readOnlyMode, |
53 | PageStore $pageStore, |
54 | RevisionStore $revStore, |
55 | TitleFormatter $titleFormatter, |
56 | ActorStore $actorStore, |
57 | Language $language |
58 | ) { |
59 | $this->config = $configFactory->makeConfig( 'discussiontools' ); |
60 | $this->dbProvider = $dbProvider; |
61 | $this->readOnlyMode = $readOnlyMode; |
62 | $this->pageStore = $pageStore; |
63 | $this->revStore = $revStore; |
64 | $this->titleFormatter = $titleFormatter; |
65 | $this->actorStore = $actorStore; |
66 | $this->language = $language; |
67 | } |
68 | |
69 | /** |
70 | * Returns true if the tables necessary for this feature haven't been created yet, |
71 | * to allow failing softly in that case. |
72 | */ |
73 | public function isDisabled(): bool { |
74 | return !$this->config->get( 'DiscussionToolsEnablePermalinksBackend' ); |
75 | } |
76 | |
77 | /** |
78 | * Find the thread items with the given name in the newest revision of every page in which they |
79 | * have appeared. |
80 | * |
81 | * @param string|string[] $itemName |
82 | * @param int|null $limit |
83 | * @return DatabaseThreadItem[] |
84 | */ |
85 | public function findNewestRevisionsByName( $itemName, ?int $limit = 50 ): array { |
86 | if ( $this->isDisabled() ) { |
87 | return []; |
88 | } |
89 | |
90 | $dbr = $this->dbProvider->getReplicaDatabase(); |
91 | $queryBuilder = $this->getIdsNamesBuilder() |
92 | ->caller( __METHOD__ ) |
93 | ->where( [ |
94 | 'it_itemname' => $itemName, |
95 | // Disallow querying for headings of sections that contain no comments. |
96 | // They all share the same name, so this would return a huge useless list on most wikis. |
97 | // (But we still store them, as we might need this data elsewhere.) |
98 | $dbr->expr( 'it_itemname', '!=', 'h-' ), |
99 | ] ); |
100 | |
101 | if ( $limit !== null ) { |
102 | $queryBuilder->limit( $limit ); |
103 | } |
104 | |
105 | $result = $this->fetchItemsResultSet( $queryBuilder ); |
106 | $revs = $this->fetchRevisionAndPageForItems( $result ); |
107 | |
108 | $threadItems = []; |
109 | foreach ( $result as $row ) { |
110 | $threadItem = $this->getThreadItemFromRow( $row, null, $revs ); |
111 | if ( $threadItem ) { |
112 | $threadItems[] = $threadItem; |
113 | } |
114 | } |
115 | return $threadItems; |
116 | } |
117 | |
118 | /** |
119 | * Find the thread items with the given ID in the newest revision of every page in which they have |
120 | * appeared. |
121 | * |
122 | * @param string|string[] $itemId |
123 | * @param int|null $limit |
124 | * @return DatabaseThreadItem[] |
125 | */ |
126 | public function findNewestRevisionsById( $itemId, ?int $limit = 50 ): array { |
127 | if ( $this->isDisabled() ) { |
128 | return []; |
129 | } |
130 | |
131 | $queryBuilder = $this->getIdsNamesBuilder() |
132 | ->caller( __METHOD__ ); |
133 | |
134 | // First find the name associated with the ID; then find by name. Otherwise we wouldn't find the |
135 | // latest revision in case comment ID changed, e.g. the comment was moved elsewhere on the page. |
136 | $itemNameQueryBuilder = $this->getIdsNamesBuilder() |
137 | ->where( [ 'itid_itemid' => $itemId ] ) |
138 | ->field( 'it_itemname' ); |
139 | // I think there may be more than 1 only in case of headings? |
140 | // For comments, any ID corresponds to just 1 name. |
141 | // Not sure how bad it is to not have limit( 1 ) here? |
142 | // It might scan a bunch of rows... |
143 | // ->limit( 1 ); |
144 | |
145 | $dbr = $this->dbProvider->getReplicaDatabase(); |
146 | $queryBuilder |
147 | ->where( [ |
148 | 'it_itemname IN (' . $itemNameQueryBuilder->getSQL() . ')', |
149 | $dbr->expr( 'it_itemname', '!=', 'h-' ), |
150 | ] ); |
151 | |
152 | if ( $limit !== null ) { |
153 | $queryBuilder->limit( $limit ); |
154 | } |
155 | |
156 | $result = $this->fetchItemsResultSet( $queryBuilder ); |
157 | $revs = $this->fetchRevisionAndPageForItems( $result ); |
158 | |
159 | $threadItems = []; |
160 | foreach ( $result as $row ) { |
161 | $threadItem = $this->getThreadItemFromRow( $row, null, $revs ); |
162 | if ( $threadItem ) { |
163 | $threadItems[] = $threadItem; |
164 | } |
165 | } |
166 | return $threadItems; |
167 | } |
168 | |
169 | /** |
170 | * Find heading items matching some text which: |
171 | * |
172 | * 1. appeared at some point in the history of the targetpage, or if this returns no results: |
173 | * 2. currently appear on a sub-page of the target page, or if this returns no results: |
174 | * 3. currently appears on any page, but only if it is a unique match |
175 | * |
176 | * @param string|string[] $heading Heading text to match |
177 | * @param int $articleId Article ID of the target page |
178 | * @param TitleValue $title Title of the target page |
179 | * @param int|null $limit |
180 | * @return DatabaseThreadItem[] |
181 | */ |
182 | public function findNewestRevisionsByHeading( |
183 | $heading, int $articleId, TitleValue $title, ?int $limit = 50 |
184 | ): array { |
185 | if ( $this->isDisabled() ) { |
186 | return []; |
187 | } |
188 | |
189 | // Mirrors CommentParser::truncateForId |
190 | $heading = trim( $this->language->truncateForDatabase( $heading, 80, '' ), '_' ); |
191 | |
192 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
193 | |
194 | // 1. Try to find items which have appeared on the page at some point |
195 | // in its history. |
196 | $itemIdInPageHistoryQueryBuilder = $this->getIdsNamesBuilder() |
197 | ->caller( __METHOD__ . ' case 1' ) |
198 | ->join( 'revision', null, [ 'rev_id = itr_revision_id' ] ) |
199 | ->where( $dbw->expr( 'itid_itemid', IExpression::LIKE, new LikeValue( |
200 | 'h-' . $heading . '-', |
201 | $dbw->anyString() |
202 | ) ) ) |
203 | // Has once appered on the specified page ID |
204 | ->where( [ 'rev_page' => $articleId ] ) |
205 | ->field( 'itid_itemid' ); |
206 | |
207 | $threadItems = $this->findNewestRevisionsByQuery( __METHOD__ . ' case 1', |
208 | $itemIdInPageHistoryQueryBuilder, $limit ); |
209 | |
210 | if ( count( $threadItems ) ) { |
211 | return $threadItems; |
212 | } |
213 | |
214 | // 2. If the thread item's database hasn't been back-filled with historical revisions |
215 | // then approach (1) may not work, instead look for matching headings the currently |
216 | // appear on sub-pages, which matches the archiving convention on most wikis. |
217 | $itemIdInSubPageQueryBuilder = $this->getIdsNamesBuilder() |
218 | ->caller( __METHOD__ . ' case 2' ) |
219 | ->join( 'page', null, [ 'page_id = itp_page_id' ] ) |
220 | ->where( $dbw->expr( 'itid_itemid', IExpression::LIKE, new LikeValue( |
221 | 'h-' . $heading . '-', |
222 | $dbw->anyString() |
223 | ) ) ) |
224 | ->where( $dbw->expr( 'page_title', IExpression::LIKE, new LikeValue( |
225 | $title->getText() . '/', |
226 | $dbw->anyString() |
227 | ) ) ) |
228 | ->where( [ 'page_namespace' => $title->getNamespace() ] ) |
229 | ->field( 'itid_itemid' ); |
230 | |
231 | $threadItems = $this->findNewestRevisionsByQuery( __METHOD__ . ' case 2', |
232 | $itemIdInSubPageQueryBuilder, $limit ); |
233 | |
234 | if ( count( $threadItems ) ) { |
235 | return $threadItems; |
236 | } |
237 | |
238 | // 3. Look for an "exact" match of the heading on any page. Because we are searching |
239 | // so broadly, only return if there is exactly one match to the heading name. |
240 | $itemIdInAnyPageQueryBuilder = $this->getIdsNamesBuilder() |
241 | ->caller( __METHOD__ . ' case 3' ) |
242 | ->join( 'page', null, [ 'page_id = itp_page_id', 'page_latest = itr_revision_id' ] ) |
243 | ->where( $dbw->expr( 'itid_itemid', IExpression::LIKE, new LikeValue( |
244 | 'h-' . $heading . '-', |
245 | $dbw->anyString() |
246 | ) ) ) |
247 | ->field( 'itid_itemid' ) |
248 | // We only care if there is one, or more than one result |
249 | ->limit( 2 ); |
250 | |
251 | // Check there is only one result in the sub-query |
252 | $itemIds = $itemIdInAnyPageQueryBuilder->fetchFieldValues(); |
253 | if ( count( $itemIds ) === 1 ) { |
254 | return $this->findNewestRevisionsByQuery( __METHOD__ . ' case 3', $itemIds[ 0 ] ); |
255 | } |
256 | |
257 | return []; |
258 | } |
259 | |
260 | /** |
261 | * @param string $fname |
262 | * @param SelectQueryBuilder|string $itemIdOrQueryBuilder Sub-query which returns item ID's, or an itemID |
263 | * @param int|null $limit |
264 | * @return DatabaseThreadItem[] |
265 | */ |
266 | private function findNewestRevisionsByQuery( $fname, $itemIdOrQueryBuilder, ?int $limit = 50 ): array { |
267 | $queryBuilder = $this->getIdsNamesBuilder()->caller( $fname . ' / ' . __METHOD__ ); |
268 | if ( $itemIdOrQueryBuilder instanceof SelectQueryBuilder ) { |
269 | $queryBuilder |
270 | ->where( [ |
271 | 'itid_itemid IN (' . $itemIdOrQueryBuilder->getSQL() . ')' |
272 | ] ); |
273 | } else { |
274 | $queryBuilder->where( [ 'itid_itemid' => $itemIdOrQueryBuilder ] ); |
275 | } |
276 | |
277 | if ( $limit !== null ) { |
278 | $queryBuilder->limit( $limit ); |
279 | } |
280 | |
281 | $result = $this->fetchItemsResultSet( $queryBuilder ); |
282 | $revs = $this->fetchRevisionAndPageForItems( $result ); |
283 | |
284 | $threadItems = []; |
285 | foreach ( $result as $row ) { |
286 | $threadItem = $this->getThreadItemFromRow( $row, null, $revs ); |
287 | if ( $threadItem ) { |
288 | $threadItems[] = $threadItem; |
289 | } |
290 | } |
291 | return $threadItems; |
292 | } |
293 | |
294 | private function fetchItemsResultSet( SelectQueryBuilder $queryBuilder ): IResultWrapper { |
295 | $queryBuilder |
296 | ->fields( [ |
297 | 'itr_id', |
298 | 'it_itemname', |
299 | 'it_timestamp', |
300 | 'it_actor', |
301 | 'itid_itemid', |
302 | 'itr_parent_id', |
303 | 'itr_transcludedfrom', |
304 | 'itr_level', |
305 | 'itr_headinglevel', |
306 | 'itr_revision_id', |
307 | ] ) |
308 | // PageStore fields for the transcluded-from page |
309 | ->leftJoin( 'page', null, [ 'page_id = itr_transcludedfrom' ] ) |
310 | ->fields( $this->pageStore->getSelectFields() ) |
311 | // ActorStore fields for the author |
312 | ->leftJoin( 'actor', null, [ 'actor_id = it_actor' ] ) |
313 | ->fields( [ 'actor_id', 'actor_name', 'actor_user' ] ) |
314 | // Parent item ID (the string, not just the primary key) |
315 | ->leftJoin( |
316 | $this->getIdsNamesBuilder() |
317 | ->caller( __METHOD__ ) |
318 | ->fields( [ |
319 | 'itr_parent__itr_id' => 'itr_id', |
320 | 'itr_parent__itid_itemid' => 'itid_itemid', |
321 | ] ), |
322 | null, |
323 | [ 'itr_parent_id = itr_parent__itr_id' ] |
324 | ) |
325 | ->field( 'itr_parent__itid_itemid' ); |
326 | |
327 | return $queryBuilder->fetchResultSet(); |
328 | } |
329 | |
330 | /** |
331 | * @param IResultWrapper $result |
332 | * @return stdClass[] |
333 | */ |
334 | private function fetchRevisionAndPageForItems( IResultWrapper $result ): array { |
335 | // This could theoretically be done in the same query as fetchItemsResultSet(), |
336 | // but the resulting query would be two screens long |
337 | // and we'd have to alias a lot of fields to avoid conflicts. |
338 | $revs = []; |
339 | foreach ( $result as $row ) { |
340 | $revs[ $row->itr_revision_id ] = null; |
341 | } |
342 | $revQueryBuilder = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() |
343 | ->caller( __METHOD__ ) |
344 | ->queryInfo( $this->revStore->getQueryInfo( [ 'page' ] ) ) |
345 | ->fields( $this->pageStore->getSelectFields() ) |
346 | ->where( $revs ? [ 'rev_id' => array_keys( $revs ) ] : '0=1' ); |
347 | $revResult = $revQueryBuilder->fetchResultSet(); |
348 | foreach ( $revResult as $row ) { |
349 | $revs[ $row->rev_id ] = $row; |
350 | } |
351 | return $revs; |
352 | } |
353 | |
354 | private function getThreadItemFromRow( |
355 | stdClass $row, ?DatabaseThreadItemSet $set, array $revs |
356 | ): ?DatabaseThreadItem { |
357 | if ( $revs[ $row->itr_revision_id ] === null ) { |
358 | // We didn't find the 'revision' table row at all, this revision is deleted. |
359 | // (The page may or may not have other non-deleted revisions.) |
360 | // Pretend the thread item doesn't exist to avoid leaking data to users who shouldn't see it. |
361 | // TODO Allow privileged users to see it (we'd need to query from 'archive') |
362 | return null; |
363 | } |
364 | |
365 | $revRow = $revs[$row->itr_revision_id]; |
366 | $page = $this->pageStore->newPageRecordFromRow( $revRow ); |
367 | $rev = $this->revStore->newRevisionFromRow( $revRow ); |
368 | if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { |
369 | // This revision is revision-deleted. |
370 | // TODO Allow privileged users to see it |
371 | return null; |
372 | } |
373 | |
374 | if ( $set && $row->itr_parent__itid_itemid ) { |
375 | $parent = $set->findCommentById( $row->itr_parent__itid_itemid ); |
376 | } else { |
377 | $parent = null; |
378 | } |
379 | |
380 | $transcludedFrom = $row->itr_transcludedfrom === null ? false : ( |
381 | $row->itr_transcludedfrom === '0' ? true : |
382 | $this->titleFormatter->getPrefixedText( |
383 | $this->pageStore->newPageRecordFromRow( $row ) |
384 | ) |
385 | ); |
386 | |
387 | if ( $row->it_timestamp !== null && $row->it_actor !== null ) { |
388 | $author = $this->actorStore->newActorFromRow( $row )->getName(); |
389 | |
390 | $item = new DatabaseCommentItem( |
391 | $page, |
392 | $rev, |
393 | $row->it_itemname, |
394 | $row->itid_itemid, |
395 | $parent, |
396 | $transcludedFrom, |
397 | (int)$row->itr_level, |
398 | $row->it_timestamp, |
399 | $author |
400 | ); |
401 | } else { |
402 | $item = new DatabaseHeadingItem( |
403 | $page, |
404 | $rev, |
405 | $row->it_itemname, |
406 | $row->itid_itemid, |
407 | $parent, |
408 | $transcludedFrom, |
409 | (int)$row->itr_level, |
410 | $row->itr_headinglevel === null ? null : (int)$row->itr_headinglevel |
411 | ); |
412 | } |
413 | |
414 | if ( $parent ) { |
415 | $parent->addReply( $item ); |
416 | } |
417 | return $item; |
418 | } |
419 | |
420 | /** |
421 | * Find the thread item set for the given revision, assuming that it is the current revision of |
422 | * its page. |
423 | */ |
424 | public function findThreadItemsInCurrentRevision( int $revId ): DatabaseThreadItemSet { |
425 | if ( $this->isDisabled() ) { |
426 | return new DatabaseThreadItemSet(); |
427 | } |
428 | |
429 | $queryBuilder = $this->getIdsNamesBuilder() |
430 | ->caller( __METHOD__ ) |
431 | ->where( [ 'itr_revision_id' => $revId ] ) |
432 | // We must process parents before their children in the loop later |
433 | ->orderBy( 'itr_id', SelectQueryBuilder::SORT_ASC ); |
434 | |
435 | $result = $this->fetchItemsResultSet( $queryBuilder ); |
436 | $revs = $this->fetchRevisionAndPageForItems( $result ); |
437 | |
438 | $set = new DatabaseThreadItemSet(); |
439 | foreach ( $result as $row ) { |
440 | $threadItem = $this->getThreadItemFromRow( $row, $set, $revs ); |
441 | if ( $threadItem ) { |
442 | $set->addThreadItem( $threadItem ); |
443 | $set->updateIdAndNameMaps( $threadItem ); |
444 | } |
445 | } |
446 | return $set; |
447 | } |
448 | |
449 | private function getIdsNamesBuilder(): SelectQueryBuilder { |
450 | $dbr = $this->dbProvider->getReplicaDatabase(); |
451 | |
452 | $queryBuilder = $dbr->newSelectQueryBuilder() |
453 | ->from( 'discussiontools_items' ) |
454 | ->join( 'discussiontools_item_pages', null, [ 'itp_items_id = it_id' ] ) |
455 | ->join( 'discussiontools_item_revisions', null, [ |
456 | 'itr_items_id = it_id', |
457 | // Only the latest revision of the items with each name |
458 | 'itr_revision_id = itp_newest_revision_id', |
459 | ] ) |
460 | ->join( 'discussiontools_item_ids', null, [ 'itid_id = itr_itemid_id' ] ); |
461 | |
462 | return $queryBuilder; |
463 | } |
464 | |
465 | /** |
466 | * @param callable $find Function that does a SELECT and returns primary key field |
467 | * @param callable $insert Function that does an INSERT IGNORE and returns last insert ID |
468 | * @param bool &$didInsert Set to true if the insert succeeds |
469 | * @param RevisionRecord $rev For error logging |
470 | * @return int Return value of whichever function succeeded |
471 | */ |
472 | private function findOrInsertId( |
473 | callable $find, callable $insert, bool &$didInsert, RevisionRecord $rev |
474 | ) { |
475 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
476 | |
477 | $id = $find( $dbw ); |
478 | if ( !$id ) { |
479 | $id = $insert( $dbw ); |
480 | if ( $id ) { |
481 | $didInsert = true; |
482 | } else { |
483 | // Maybe it's there, but we can't see it due to REPEATABLE_READ? |
484 | // Try again in another connection. (T339882, T322701) |
485 | $dbwAnother = $this->dbProvider->getMainLB() |
486 | ->getConnection( DB_PRIMARY, [], false, ILoadBalancer::CONN_TRX_AUTOCOMMIT ); |
487 | $id = $find( $dbwAnother ); |
488 | if ( !$id ) { |
489 | throw new NormalizedException( |
490 | "Database can't find our row and won't let us insert it on page {page} revision {revision}", |
491 | [ |
492 | 'page' => $rev->getPageId(), |
493 | 'revision' => $rev->getId(), |
494 | ] |
495 | ); |
496 | } |
497 | } |
498 | } |
499 | return $id; |
500 | } |
501 | |
502 | /** |
503 | * Store the thread item set. |
504 | * |
505 | * @param RevisionRecord $rev |
506 | * @param ContentThreadItemSet $threadItemSet |
507 | * @throws TimestampException |
508 | * @throws DBError |
509 | * @throws Exception |
510 | * @return bool |
511 | */ |
512 | public function insertThreadItems( RevisionRecord $rev, ContentThreadItemSet $threadItemSet ): bool { |
513 | if ( $this->readOnlyMode->isReadOnly() ) { |
514 | return false; |
515 | } |
516 | |
517 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
518 | $didInsert = false; |
519 | $method = __METHOD__; |
520 | |
521 | // Map of item IDs (strings) to their discussiontools_item_ids.itid_id field values (ints) |
522 | $itemIdsIds = []; |
523 | '@phan-var array<string,int> $itemIdsIds'; |
524 | // Map of item IDs (strings) to their discussiontools_items.it_id field values (ints) |
525 | $itemsIds = []; |
526 | '@phan-var array<string,int> $itemsIds'; |
527 | |
528 | // Insert or find discussiontools_item_ids rows, fill in itid_id field values. |
529 | // (This is not in a transaction. Orphaned rows in this table are harmlessly ignored, |
530 | // and long transactions caused performance issues on Wikimedia wikis: T315353#8218914.) |
531 | foreach ( $threadItemSet->getThreadItems() as $item ) { |
532 | $itemIdsId = $this->findOrInsertId( |
533 | static function ( IReadableDatabase $dbw ) use ( $item, $method ) { |
534 | $ids = [ $item->getId() ]; |
535 | if ( $item->getLegacyId() !== null ) { |
536 | // Avoid duplicates if the item exists under the legacy ID |
537 | // (i.e. with trailing underscores in the title part). |
538 | // The actual fixing of IDs is done by a maintenance script |
539 | // FixTrailingWhitespaceIds, as archived talk pages are unlikely |
540 | // to be edited again in the future. |
541 | // Once FixTrailingWhitespaceIds has run on and enough time has |
542 | // passed, we can remove all legacy ID code (again). |
543 | $ids[] = $item->getLegacyId(); |
544 | } |
545 | return $dbw->newSelectQueryBuilder() |
546 | ->from( 'discussiontools_item_ids' ) |
547 | ->field( 'itid_id' ) |
548 | ->where( [ 'itid_itemid' => $ids ] ) |
549 | ->caller( $method ) |
550 | ->fetchField(); |
551 | }, |
552 | static function ( IDatabase $dbw ) use ( $item, $method ) { |
553 | $dbw->newInsertQueryBuilder() |
554 | ->table( 'discussiontools_item_ids' ) |
555 | ->row( [ 'itid_itemid' => $item->getId() ] ) |
556 | ->ignore() |
557 | ->caller( $method ) |
558 | ->execute(); |
559 | return $dbw->affectedRows() ? $dbw->insertId() : null; |
560 | }, |
561 | $didInsert, |
562 | $rev |
563 | ); |
564 | $itemIdsIds[ $item->getId() ] = $itemIdsId; |
565 | } |
566 | |
567 | // Insert or find discussiontools_items rows, fill in it_id field values. |
568 | // (This is not in a transaction. Orphaned rows in this table are harmlessly ignored, |
569 | // and long transactions caused performance issues on Wikimedia wikis: T315353#8218914.) |
570 | foreach ( $threadItemSet->getThreadItems() as $item ) { |
571 | $itemsId = $this->findOrInsertId( |
572 | static function ( IReadableDatabase $dbw ) use ( $item, $method ) { |
573 | return $dbw->newSelectQueryBuilder() |
574 | ->from( 'discussiontools_items' ) |
575 | ->field( 'it_id' ) |
576 | ->where( [ 'it_itemname' => $item->getName() ] ) |
577 | ->caller( $method ) |
578 | ->fetchField(); |
579 | }, |
580 | function ( IDatabase $dbw ) use ( $item, $method ) { |
581 | $dbw->newInsertQueryBuilder() |
582 | ->table( 'discussiontools_items' ) |
583 | ->row( |
584 | [ |
585 | 'it_itemname' => $item->getName(), |
586 | ] + |
587 | ( $item instanceof CommentItem ? [ |
588 | 'it_timestamp' => |
589 | $dbw->timestamp( $item->getTimestampString() ), |
590 | 'it_actor' => |
591 | $this->actorStore->findActorIdByName( $item->getAuthor(), $dbw ), |
592 | ] : [] ) |
593 | ) |
594 | ->ignore() |
595 | ->caller( $method ) |
596 | ->execute(); |
597 | return $dbw->affectedRows() ? $dbw->insertId() : null; |
598 | }, |
599 | $didInsert, |
600 | $rev |
601 | ); |
602 | $itemsIds[ $item->getId() ] = $itemsId; |
603 | } |
604 | |
605 | // Insert or update discussiontools_item_pages and discussiontools_item_revisions rows. |
606 | // This IS in a transaction. We don't really want rows for different items on the same |
607 | // page to point to different revisions. |
608 | $dbw->doAtomicSection( $method, /** @throws TimestampException */ function ( IDatabase $dbw ) use ( |
609 | $method, $rev, $threadItemSet, $itemsIds, $itemIdsIds, &$didInsert |
610 | ) { |
611 | // Map of item IDs (strings) to their discussiontools_item_revisions.itr_id field values (ints) |
612 | $itemRevisionsIds = []; |
613 | '@phan-var array<string,int> $itemRevisionsIds'; |
614 | |
615 | $revUpdateRows = []; |
616 | // Insert or update discussiontools_item_pages rows. |
617 | foreach ( $threadItemSet->getThreadItems() as $item ) { |
618 | // Update (or insert) the references to oldest/newest item revision. |
619 | // The page revision we're processing is usually the newest one, but it doesn't have to be |
620 | // (in case of backfilling using the maintenance script, or in case of revisions being |
621 | // imported), so we need all these funky queries to see if we need to update oldest/newest. |
622 | |
623 | $itemPagesRow = $dbw->newSelectQueryBuilder() |
624 | ->from( 'discussiontools_item_pages' ) |
625 | ->join( 'revision', 'revision_oldest', [ 'itp_oldest_revision_id = revision_oldest.rev_id' ] ) |
626 | ->join( 'revision', 'revision_newest', [ 'itp_newest_revision_id = revision_newest.rev_id' ] ) |
627 | ->field( 'itp_id' ) |
628 | ->field( 'itp_oldest_revision_id' ) |
629 | ->field( 'itp_newest_revision_id' ) |
630 | ->field( 'revision_oldest.rev_timestamp', 'oldest_rev_timestamp' ) |
631 | ->field( 'revision_newest.rev_timestamp', 'newest_rev_timestamp' ) |
632 | ->where( [ |
633 | 'itp_items_id' => $itemsIds[ $item->getId() ], |
634 | 'itp_page_id' => $rev->getPageId(), |
635 | ] ) |
636 | ->caller( $method ) |
637 | ->fetchRow(); |
638 | if ( $itemPagesRow === false ) { |
639 | $dbw->newInsertQueryBuilder() |
640 | ->table( 'discussiontools_item_pages' ) |
641 | ->row( [ |
642 | 'itp_items_id' => $itemsIds[ $item->getId() ], |
643 | 'itp_page_id' => $rev->getPageId(), |
644 | 'itp_oldest_revision_id' => $rev->getId(), |
645 | 'itp_newest_revision_id' => $rev->getId(), |
646 | ] ) |
647 | ->ignore() |
648 | ->caller( $method ) |
649 | ->execute(); |
650 | } else { |
651 | $oldestTime = ( new MWTimestamp( $itemPagesRow->oldest_rev_timestamp ) )->getTimestamp( TS_MW ); |
652 | $newestTime = ( new MWTimestamp( $itemPagesRow->newest_rev_timestamp ) )->getTimestamp( TS_MW ); |
653 | $currentTime = $rev->getTimestamp(); |
654 | |
655 | $oldestId = (int)$itemPagesRow->itp_oldest_revision_id; |
656 | $newestId = (int)$itemPagesRow->itp_newest_revision_id; |
657 | $currentId = $rev->getId(); |
658 | |
659 | $updatePageField = null; |
660 | if ( [ $oldestTime, $oldestId ] > [ $currentTime, $currentId ] ) { |
661 | $updatePageField = 'itp_oldest_revision_id'; |
662 | } elseif ( [ $newestTime, $newestId ] < [ $currentTime, $currentId ] ) { |
663 | $updatePageField = 'itp_newest_revision_id'; |
664 | } |
665 | if ( $updatePageField ) { |
666 | $dbw->newUpdateQueryBuilder() |
667 | ->table( 'discussiontools_item_pages' ) |
668 | ->set( [ $updatePageField => $rev->getId() ] ) |
669 | ->where( [ 'itp_id' => $itemPagesRow->itp_id ] ) |
670 | ->caller( $method ) |
671 | ->execute(); |
672 | if ( $oldestId !== $newestId ) { |
673 | // This causes most rows in discussiontools_item_revisions referring to the previously |
674 | // oldest/newest revision to be unused, so try re-using them. |
675 | $revUpdateRows[ $itemsIds[ $item->getId() ] ] = $itemPagesRow->$updatePageField; |
676 | } |
677 | } |
678 | } |
679 | } |
680 | |
681 | // Insert or update discussiontools_item_revisions rows, fill in itr_id field values. |
682 | foreach ( $threadItemSet->getThreadItems() as $item ) { |
683 | $transcl = $item->getTranscludedFrom(); |
684 | $newOrUpdateRevRow = |
685 | [ |
686 | 'itr_itemid_id' => $itemIdsIds[ $item->getId() ], |
687 | 'itr_revision_id' => $rev->getId(), |
688 | 'itr_items_id' => $itemsIds[ $item->getId() ], |
689 | 'itr_parent_id' => |
690 | // This assumes that parent items were processed first |
691 | $item->getParent() ? $itemRevisionsIds[ $item->getParent()->getId() ] : null, |
692 | 'itr_transcludedfrom' => |
693 | $transcl === false ? null : ( |
694 | $transcl === true ? 0 : |
695 | $this->pageStore->getPageByText( $transcl )->getId() |
696 | ), |
697 | 'itr_level' => $item->getLevel(), |
698 | ] + |
699 | ( $item instanceof HeadingItem ? [ |
700 | 'itr_headinglevel' => $item->isPlaceholderHeading() ? null : $item->getHeadingLevel(), |
701 | ] : [] ); |
702 | |
703 | $itemRevisionsConds = [ |
704 | 'itr_itemid_id' => $itemIdsIds[ $item->getId() ], |
705 | 'itr_items_id' => $itemsIds[ $item->getId() ], |
706 | 'itr_revision_id' => $rev->getId(), |
707 | ]; |
708 | $itemRevisionsId = $dbw->newSelectQueryBuilder() |
709 | ->from( 'discussiontools_item_revisions' ) |
710 | ->field( 'itr_id' ) |
711 | ->where( $itemRevisionsConds ) |
712 | ->caller( $method ) |
713 | ->fetchField(); |
714 | if ( $itemRevisionsId === false ) { |
715 | $itemRevisionsUpdateId = null; |
716 | if ( isset( $revUpdateRows[ $itemsIds[ $item->getId() ] ] ) ) { |
717 | $itemRevisionsUpdateId = $dbw->newSelectQueryBuilder() |
718 | ->from( 'discussiontools_item_revisions' ) |
719 | ->field( 'itr_id' ) |
720 | ->where( [ |
721 | 'itr_revision_id' => $revUpdateRows[ $itemsIds[ $item->getId() ] ], |
722 | // We only keep up to 2 discussiontools_item_revisions rows with the same |
723 | // (itr_itemid_id, itr_items_id) pair, for the oldest and newest revision known. |
724 | // Here we find any rows we don't want to keep and re-use them. |
725 | 'itr_itemid_id' => $itemIdsIds[ $item->getId() ], |
726 | 'itr_items_id' => $itemsIds[ $item->getId() ], |
727 | ] ) |
728 | ->caller( $method ) |
729 | ->fetchField(); |
730 | // The row to re-use may not be found if it has a different itr_itemid_id than the row |
731 | // we want to add. |
732 | } |
733 | if ( $itemRevisionsUpdateId ) { |
734 | $dbw->newUpdateQueryBuilder() |
735 | ->table( 'discussiontools_item_revisions' ) |
736 | ->set( $newOrUpdateRevRow ) |
737 | ->where( [ 'itr_id' => $itemRevisionsUpdateId ] ) |
738 | ->caller( $method ) |
739 | ->execute(); |
740 | $itemRevisionsId = $itemRevisionsUpdateId; |
741 | $didInsert = true; |
742 | } else { |
743 | $itemRevisionsId = $this->findOrInsertId( |
744 | static function ( IReadableDatabase $dbw ) use ( $itemRevisionsConds, $method ) { |
745 | return $dbw->newSelectQueryBuilder() |
746 | ->from( 'discussiontools_item_revisions' ) |
747 | ->field( 'itr_id' ) |
748 | ->where( $itemRevisionsConds ) |
749 | ->caller( $method ) |
750 | ->fetchField(); |
751 | }, |
752 | static function ( IDatabase $dbw ) use ( $newOrUpdateRevRow, $method ) { |
753 | $dbw->newInsertQueryBuilder() |
754 | ->table( 'discussiontools_item_revisions' ) |
755 | ->row( $newOrUpdateRevRow ) |
756 | // Fix rows with corrupted itr_items_id=0, |
757 | // which are causing conflicts (T339882, T343859#9185559) |
758 | ->onDuplicateKeyUpdate() |
759 | ->uniqueIndexFields( [ 'itr_itemid_id', 'itr_revision_id' ] ) |
760 | // Omit redundant updates to avoid warnings (T353432) |
761 | ->set( array_diff_key( |
762 | $newOrUpdateRevRow, |
763 | [ 'itr_itemid_id' => true, 'itr_revision_id' => true ] |
764 | ) ) |
765 | ->caller( $method ) |
766 | ->execute(); |
767 | return $dbw->affectedRows() ? $dbw->insertId() : null; |
768 | }, |
769 | $didInsert, |
770 | $rev |
771 | ); |
772 | } |
773 | } |
774 | |
775 | $itemRevisionsIds[ $item->getId() ] = $itemRevisionsId; |
776 | } |
777 | }, $dbw::ATOMIC_CANCELABLE ); |
778 | |
779 | return $didInsert; |
780 | } |
781 | } |