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