Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.11% covered (success)
91.11%
410 / 450
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ThreadItemStore
91.11% covered (success)
91.11%
410 / 450
46.15% covered (danger)
46.15%
6 / 13
74.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 isDisabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findNewestRevisionsByName
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 findNewestRevisionsById
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
 findNewestRevisionsByHeading
95.92% covered (success)
95.92%
47 / 49
0.00% covered (danger)
0.00%
0 / 1
5
 findNewestRevisionsByQuery
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 fetchItemsResultSet
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
 fetchRevisionAndPageForItems
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getThreadItemFromRow
88.10% covered (warning)
88.10%
37 / 42
0.00% covered (danger)
0.00%
0 / 1
11.20
 findThreadItemsInCurrentRevision
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getIdsNamesBuilder
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 findOrInsertId
38.89% covered (danger)
38.89%
7 / 18
0.00% covered (danger)
0.00%
0 / 1
7.65
 insertThreadItems
98.03% covered (success)
98.03%
199 / 203
0.00% covered (danger)
0.00%
0 / 1
24
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use Exception;
6use Language;
7use MediaWiki\Config\Config;
8use MediaWiki\Config\ConfigFactory;
9use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem;
10use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseCommentItem;
11use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseHeadingItem;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem;
13use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem;
14use MediaWiki\Page\PageStore;
15use MediaWiki\Revision\RevisionRecord;
16use MediaWiki\Revision\RevisionStore;
17use MediaWiki\Title\TitleFormatter;
18use MediaWiki\Title\TitleValue;
19use MediaWiki\User\ActorStore;
20use MediaWiki\Utils\MWTimestamp;
21use stdClass;
22use Wikimedia\NormalizedException\NormalizedException;
23use Wikimedia\Rdbms\DBError;
24use Wikimedia\Rdbms\IDatabase;
25use Wikimedia\Rdbms\IExpression;
26use Wikimedia\Rdbms\ILBFactory;
27use Wikimedia\Rdbms\ILoadBalancer;
28use Wikimedia\Rdbms\IReadableDatabase;
29use Wikimedia\Rdbms\IResultWrapper;
30use Wikimedia\Rdbms\LikeValue;
31use Wikimedia\Rdbms\ReadOnlyMode;
32use Wikimedia\Rdbms\SelectQueryBuilder;
33use Wikimedia\Timestamp\TimestampException;
34
35/**
36 * Stores and fetches ThreadItemSets from the database.
37 */
38class 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}