Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.22% covered (success)
91.22%
405 / 444
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ThreadItemStore
91.22% covered (success)
91.22%
405 / 444
46.15% covered (danger)
46.15%
6 / 13
73.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
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.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 findNewestRevisionsById
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
5
 findNewestRevisionsByHeading
96.00% covered (success)
96.00%
48 / 50
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.49% covered (success)
98.49%
196 / 199
0.00% covered (danger)
0.00%
0 / 1
23
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use Exception;
6use MediaWiki\Config\Config;
7use MediaWiki\Config\ConfigFactory;
8use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem;
9use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseCommentItem;
10use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseHeadingItem;
11use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem;
13use MediaWiki\MediaWikiServices;
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
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}