Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.67% covered (warning)
54.67%
123 / 225
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ThreadItemStore
54.67% covered (warning)
54.67%
123 / 225
20.00% covered (danger)
20.00%
2 / 10
325.67
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
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 findNewestRevisionsById
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 fetchItemsResultSet
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 fetchRevisionAndPageForItems
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getThreadItemFromRow
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
132
 findThreadItemsInCurrentRevision
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getIdsNamesBuilder
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 insertThreadItems
97.46% covered (success)
97.46%
115 / 118
0.00% covered (danger)
0.00%
0 / 1
23
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use Config;
6use ConfigFactory;
7use MediaWiki\Extension\DiscussionTools\ThreadItem\CommentItem;
8use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseCommentItem;
9use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseHeadingItem;
10use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem;
11use MediaWiki\Extension\DiscussionTools\ThreadItem\HeadingItem;
12use MediaWiki\Page\PageStore;
13use MediaWiki\Revision\RevisionRecord;
14use MediaWiki\Revision\RevisionStore;
15use MediaWiki\User\ActorStore;
16use MWTimestamp;
17use ReadOnlyMode;
18use stdClass;
19use TitleFormatter;
20use Wikimedia\Rdbms\ILBFactory;
21use Wikimedia\Rdbms\ILoadBalancer;
22use Wikimedia\Rdbms\IResultWrapper;
23use Wikimedia\Rdbms\SelectQueryBuilder;
24
25/**
26 * Stores and fetches ThreadItemSets from the database.
27 */
28class ThreadItemStore {
29
30    private Config $config;
31    private ILoadBalancer $loadBalancer;
32    private ReadOnlyMode $readOnlyMode;
33    private PageStore $pageStore;
34    private RevisionStore $revStore;
35    private TitleFormatter $titleFormatter;
36    private ActorStore $actorStore;
37
38    public function __construct(
39        ConfigFactory $configFactory,
40        ILBFactory $lbFactory,
41        ReadOnlyMode $readOnlyMode,
42        PageStore $pageStore,
43        RevisionStore $revStore,
44        TitleFormatter $titleFormatter,
45        ActorStore $actorStore
46    ) {
47        $this->config = $configFactory->makeConfig( 'discussiontools' );
48        $this->loadBalancer = $lbFactory->getMainLB();
49        $this->readOnlyMode = $readOnlyMode;
50        $this->pageStore = $pageStore;
51        $this->revStore = $revStore;
52        $this->titleFormatter = $titleFormatter;
53        $this->actorStore = $actorStore;
54    }
55
56    /**
57     * Returns true if the tables necessary for this feature haven't been created yet,
58     * to allow failing softly in that case.
59     *
60     * @return bool
61     */
62    private function isDisabled(): bool {
63        return !$this->config->get( 'DiscussionToolsEnablePermalinksBackend' );
64    }
65
66    /**
67     * Find the thread items with the given name in the newest revision of every page in which they
68     * have appeared.
69     *
70     * @param string|string[] $itemName
71     * @return DatabaseThreadItem[]
72     */
73    public function findNewestRevisionsByName( $itemName ): array {
74        if ( $this->isDisabled() ) {
75            return [];
76        }
77
78        $queryBuilder = $this->getIdsNamesBuilder()
79            ->where( [
80                'it_itemname' => $itemName,
81                // Disallow querying for headings of sections that contain no comments.
82                // They all share the same name, so this would return a huge useless list on most wikis.
83                // (But we still store them, as we might need this data elsewhere.)
84                "it_itemname != 'h-'",
85            ] );
86
87        $result = $this->fetchItemsResultSet( $queryBuilder );
88        $revs = $this->fetchRevisionAndPageForItems( $result );
89
90        $threadItems = [];
91        foreach ( $result as $row ) {
92            $threadItem = $this->getThreadItemFromRow( $row, null, $revs );
93            if ( $threadItem ) {
94                $threadItems[] = $threadItem;
95            }
96        }
97        return $threadItems;
98    }
99
100    /**
101     * Find the thread items with the given ID in the newest revision of every page in which they have
102     * appeared.
103     *
104     * @param string|string[] $itemId
105     * @return DatabaseThreadItem[]
106     */
107    public function findNewestRevisionsById( $itemId ): array {
108        if ( $this->isDisabled() ) {
109            return [];
110        }
111
112        $queryBuilder = $this->getIdsNamesBuilder();
113
114        // First find the name associated with the ID; then find by name. Otherwise we wouldn't find the
115        // latest revision in case comment ID changed, e.g. the comment was moved elsewhere on the page.
116        $itemNameQueryBuilder = $this->getIdsNamesBuilder()
117            ->where( [ 'itid_itemid' => $itemId ] )
118            ->field( 'it_itemname' );
119            // I think there may be more than 1 only in case of headings?
120            // For comments, any ID corresponds to just 1 name.
121            // Not sure how bad it is to not have limit( 1 ) here?
122            // It might scan a bunch of rows...
123            // ->limit( 1 );
124
125        $queryBuilder
126            ->where( [
127                'it_itemname IN (' . $itemNameQueryBuilder->getSQL() . ')',
128                "it_itemname != 'h-'",
129            ] );
130
131        $result = $this->fetchItemsResultSet( $queryBuilder );
132        $revs = $this->fetchRevisionAndPageForItems( $result );
133
134        $threadItems = [];
135        foreach ( $result as $row ) {
136            $threadItem = $this->getThreadItemFromRow( $row, null, $revs );
137            if ( $threadItem ) {
138                $threadItems[] = $threadItem;
139            }
140        }
141        return $threadItems;
142    }
143
144    /**
145     * @param SelectQueryBuilder $queryBuilder
146     * @return IResultWrapper
147     */
148    private function fetchItemsResultSet( SelectQueryBuilder $queryBuilder ): IResultWrapper {
149        $queryBuilder
150            ->fields( [
151                'itr_id',
152                'it_itemname',
153                'it_timestamp',
154                'it_actor',
155                'itid_itemid',
156                'itr_parent_id',
157                'itr_transcludedfrom',
158                'itr_level',
159                'itr_headinglevel',
160                'itr_revision_id',
161            ] )
162            // PageStore fields for the transcluded-from page
163            ->leftJoin( 'page', null, [ 'page_id = itr_transcludedfrom' ] )
164            ->fields( $this->pageStore->getSelectFields() )
165            // ActorStore fields for the author
166            ->leftJoin( 'actor', null, [ 'actor_id = it_actor' ] )
167            ->fields( [ 'actor_id', 'actor_name', 'actor_user' ] )
168            // Parent item ID (the string, not just the primary key)
169            ->leftJoin(
170                $this->getIdsNamesBuilder()
171                    ->fields( [
172                        'itr_parent__itr_id' => 'itr_id',
173                        'itr_parent__itid_itemid' => 'itid_itemid',
174                    ] ),
175                null,
176                [ 'itr_parent_id = itr_parent__itr_id' ]
177            )
178            ->field( 'itr_parent__itid_itemid' );
179
180        return $queryBuilder->fetchResultSet();
181    }
182
183    /**
184     * @param IResultWrapper $result
185     * @return stdClass[]
186     */
187    private function fetchRevisionAndPageForItems( IResultWrapper $result ): array {
188        // This could theoretically be done in the same query as fetchItemsResultSet(),
189        // but the resulting query would be two screens long
190        // and we'd have to alias a lot of fields to avoid conflicts.
191        $revs = [];
192        foreach ( $result as $row ) {
193            $revs[ $row->itr_revision_id ] = null;
194        }
195        $revQueryBuilder = $this->loadBalancer->getConnection( DB_REPLICA )->newSelectQueryBuilder()
196            ->queryInfo( $this->revStore->getQueryInfo( [ 'page' ] ) )
197            ->fields( $this->pageStore->getSelectFields() )
198            ->where( $revs ? [ 'rev_id' => array_keys( $revs ) ] : '0=1' );
199        $revResult = $revQueryBuilder->fetchResultSet();
200        foreach ( $revResult as $row ) {
201            $revs[ $row->rev_id ] = $row;
202        }
203        return $revs;
204    }
205
206    /**
207     * @param stdClass $row
208     * @param DatabaseThreadItemSet|null $set
209     * @param array $revs
210     * @return DatabaseThreadItem|null
211     */
212    private function getThreadItemFromRow(
213        stdClass $row, ?DatabaseThreadItemSet $set, array $revs
214    ): ?DatabaseThreadItem {
215        if ( $revs[ $row->itr_revision_id ] === null ) {
216            // We didn't find the 'revision' table row at all, this revision is deleted.
217            // (The page may or may not have other non-deleted revisions.)
218            // Pretend the thread item doesn't exist to avoid leaking data to users who shouldn't see it.
219            // TODO Allow privileged users to see it (we'd need to query from 'archive')
220            return null;
221        }
222
223        $revRow = $revs[$row->itr_revision_id];
224        $page = $this->pageStore->newPageRecordFromRow( $revRow );
225        $rev = $this->revStore->newRevisionFromRow( $revRow );
226        if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
227            // This revision is revision-deleted.
228            // TODO Allow privileged users to see it
229            return null;
230        }
231
232        if ( $set && $row->itr_parent__itid_itemid ) {
233            $parent = $set->findCommentById( $row->itr_parent__itid_itemid );
234        } else {
235            $parent = null;
236        }
237
238        $transcludedFrom = $row->itr_transcludedfrom === null ? false : (
239            $row->itr_transcludedfrom === '0' ? true :
240                $this->titleFormatter->getPrefixedText(
241                    $this->pageStore->newPageRecordFromRow( $row )
242                )
243        );
244
245        if ( $row->it_timestamp !== null && $row->it_actor !== null ) {
246            $author = $this->actorStore->newActorFromRow( $row )->getName();
247
248            $item = new DatabaseCommentItem(
249                $page,
250                $rev,
251                $row->it_itemname,
252                $row->itid_itemid,
253                $parent,
254                $transcludedFrom,
255                (int)$row->itr_level,
256                $row->it_timestamp,
257                $author
258            );
259        } else {
260            $item = new DatabaseHeadingItem(
261                $page,
262                $rev,
263                $row->it_itemname,
264                $row->itid_itemid,
265                $parent,
266                $transcludedFrom,
267                (int)$row->itr_level,
268                $row->itr_headinglevel === null ? null : (int)$row->itr_headinglevel
269            );
270        }
271
272        if ( $parent ) {
273            $parent->addReply( $item );
274        }
275        return $item;
276    }
277
278    /**
279     * Find the thread item set for the given revision, assuming that it is the current revision of
280     * its page.
281     *
282     * @param int $revId
283     * @return DatabaseThreadItemSet
284     */
285    public function findThreadItemsInCurrentRevision( int $revId ): DatabaseThreadItemSet {
286        if ( $this->isDisabled() ) {
287            return new DatabaseThreadItemSet();
288        }
289
290        $queryBuilder = $this->getIdsNamesBuilder();
291        $queryBuilder
292            ->where( [ 'itr_revision_id' => $revId ] )
293            // We must process parents before their children in the loop later
294            ->orderBy( 'itr_id', SelectQueryBuilder::SORT_ASC );
295
296        $result = $this->fetchItemsResultSet( $queryBuilder );
297        $revs = $this->fetchRevisionAndPageForItems( $result );
298
299        $set = new DatabaseThreadItemSet();
300        foreach ( $result as $row ) {
301            $threadItem = $this->getThreadItemFromRow( $row, $set, $revs );
302            if ( $threadItem ) {
303                $set->addThreadItem( $threadItem );
304                $set->updateIdAndNameMaps( $threadItem );
305            }
306        }
307        return $set;
308    }
309
310    /**
311     * @return SelectQueryBuilder
312     */
313    private function getIdsNamesBuilder(): SelectQueryBuilder {
314        $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
315
316        $queryBuilder = $dbr->newSelectQueryBuilder()
317            ->from( 'discussiontools_items' )
318            ->join( 'discussiontools_item_pages', null, [ 'itp_items_id = it_id' ] )
319            ->join( 'discussiontools_item_revisions', null, [
320                'itr_items_id = it_id',
321                // Only the latest revision of the items with each name
322                'itr_revision_id = itp_newest_revision_id',
323            ] )
324            ->join( 'discussiontools_item_ids', null, [ 'itid_id = itr_itemid_id' ] );
325
326        return $queryBuilder;
327    }
328
329    /**
330     * Store the thread item set.
331     *
332     * @param RevisionRecord $rev
333     * @param ThreadItemSet $threadItemSet
334     * @return bool
335     */
336    public function insertThreadItems( RevisionRecord $rev, ThreadItemSet $threadItemSet ): bool {
337        if ( $this->isDisabled() || $this->readOnlyMode->isReadOnly() ) {
338            return false;
339        }
340
341        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
342        $didInsert = false;
343        $method = __METHOD__;
344
345        // Map of item IDs (strings) to their discussiontools_item_ids.itid_id field values (ints)
346        $itemIdsIds = [];
347        '@phan-var array<string,int> $itemIdsIds';
348        // Map of item IDs (strings) to their discussiontools_items.it_id field values (ints)
349        $itemsIds = [];
350        '@phan-var array<string,int> $itemsIds';
351
352        // Insert or find discussiontools_item_ids rows, fill in itid_id field values.
353        // (This is not in a transaction. Orphaned rows in this table are harmlessly ignored,
354        // and long transactions caused performance issues on Wikimedia wikis: T315353#8218914.)
355        foreach ( $threadItemSet->getThreadItems() as $item ) {
356            $itemIdsId = $dbw->newSelectQueryBuilder()
357                ->from( 'discussiontools_item_ids' )
358                ->field( 'itid_id' )
359                ->where( [ 'itid_itemid' => $item->getId() ] )
360                ->caller( $method )
361                ->fetchField();
362            if ( $itemIdsId === false ) {
363                // Use upsert() instead of insert() to handle race conditions (T322701).
364                // Do a SELECT first to avoid unnecessary replication and bumping auto-increment values.
365                // (We can't just use INSERT IGNORE and then do a SELECT because of implicit
366                // transactions with REPEATABLE READ.)
367                $dbw->upsert(
368                    'discussiontools_item_ids',
369                    [
370                        'itid_itemid' => $item->getId(),
371                    ],
372                    'itid_itemid',
373                    // We update nothing, as the rows will be identical, but this can't be empty
374                    [ 'itid_id = itid_id' ],
375                    $method
376                );
377                $itemIdsId = $dbw->insertId();
378                $didInsert = true;
379            }
380            $itemIdsIds[ $item->getId() ] = $itemIdsId;
381        }
382
383        // Insert or find discussiontools_items rows, fill in it_id field values.
384        // (This is not in a transaction. Orphaned rows in this table are harmlessly ignored,
385        // and long transactions caused performance issues on Wikimedia wikis: T315353#8218914.)
386        foreach ( $threadItemSet->getThreadItems() as $item ) {
387            $itemsId = $dbw->newSelectQueryBuilder()
388                ->from( 'discussiontools_items' )
389                ->field( 'it_id' )
390                ->where( [ 'it_itemname' => $item->getName() ] )
391                ->caller( $method )
392                ->fetchField();
393            if ( $itemsId === false ) {
394                // Use upsert() instead of insert() to handle race conditions (T322701).
395                // Do a SELECT first to avoid unnecessary replication and bumping auto-increment values.
396                // (We can't just use INSERT IGNORE and then do a SELECT because of implicit
397                // transactions with REPEATABLE READ.)
398                $dbw->upsert(
399                    'discussiontools_items',
400                    [
401                        'it_itemname' => $item->getName(),
402                    ] +
403                    ( $item instanceof CommentItem ? [
404                        'it_timestamp' =>
405                            $dbw->timestamp( $item->getTimestampString() ),
406                        'it_actor' =>
407                            $this->actorStore->findActorIdByName( $item->getAuthor(), $dbw ),
408                    ] : [] ),
409                    'it_itemname',
410                    // We update nothing, as the rows will be identical, but this can't be empty
411                    [ 'it_id = it_id' ],
412                    $method
413                );
414                $itemsId = $dbw->insertId();
415                $didInsert = true;
416            }
417            $itemsIds[ $item->getId() ] = $itemsId;
418        }
419
420        // Insert or update discussiontools_item_pages and discussiontools_item_revisions rows.
421        // This IS in a transaction. We don't really want rows for different items on the same
422        // page to point to different revisions.
423        $dbw->doAtomicSection( $method, function ( $dbw ) use (
424            $method, $rev, $threadItemSet, $itemsIds, $itemIdsIds, &$didInsert
425        ) {
426            // Map of item IDs (strings) to their discussiontools_item_revisions.itr_id field values (ints)
427            $itemRevisionsIds = [];
428            '@phan-var array<string,int> $itemRevisionsIds';
429
430            $revUpdateRows = [];
431            // Insert or update discussiontools_item_pages rows.
432            foreach ( $threadItemSet->getThreadItems() as $item ) {
433                // Update (or insert) the references to oldest/newest item revision.
434                // The page revision we're processing is usually the newest one, but it doesn't have to be
435                // (in case of backfilling using the maintenance script, or in case of revisions being
436                // imported), so we need all these funky queries to see if we need to update oldest/newest.
437
438                $itemPagesRow = $dbw->newSelectQueryBuilder()
439                    ->from( 'discussiontools_item_pages' )
440                    ->join( 'revision', 'revision_oldest', [ 'itp_oldest_revision_id = revision_oldest.rev_id' ] )
441                    ->join( 'revision', 'revision_newest', [ 'itp_newest_revision_id = revision_newest.rev_id' ] )
442                    ->field( 'itp_id' )
443                    ->field( 'itp_oldest_revision_id' )
444                    ->field( 'itp_newest_revision_id' )
445                    ->field( 'revision_oldest.rev_timestamp', 'oldest_rev_timestamp' )
446                    ->field( 'revision_newest.rev_timestamp', 'newest_rev_timestamp' )
447                    ->where( [
448                        'itp_items_id' => $itemsIds[ $item->getId() ],
449                        'itp_page_id' => $rev->getPageId(),
450                    ] )
451                    ->fetchRow();
452                if ( $itemPagesRow === false ) {
453                    $dbw->insert(
454                        'discussiontools_item_pages',
455                        [
456                            'itp_items_id' => $itemsIds[ $item->getId() ],
457                            'itp_page_id' => $rev->getPageId(),
458                            'itp_oldest_revision_id' => $rev->getId(),
459                            'itp_newest_revision_id' => $rev->getId(),
460                        ],
461                        $method
462                    );
463                } else {
464                    $oldestTime = ( new MWTimestamp( $itemPagesRow->oldest_rev_timestamp ) )->getTimestamp( TS_MW );
465                    $newestTime = ( new MWTimestamp( $itemPagesRow->newest_rev_timestamp ) )->getTimestamp( TS_MW );
466                    $currentTime = $rev->getTimestamp();
467
468                    $oldestId = (int)$itemPagesRow->itp_oldest_revision_id;
469                    $newestId = (int)$itemPagesRow->itp_newest_revision_id;
470                    $currentId = $rev->getId();
471
472                    $updatePageField = null;
473                    if ( [ $oldestTime, $oldestId ] > [ $currentTime, $currentId ] ) {
474                        $updatePageField = 'itp_oldest_revision_id';
475                    } elseif ( [ $newestTime, $newestId ] < [ $currentTime, $currentId ] ) {
476                        $updatePageField = 'itp_newest_revision_id';
477                    }
478                    if ( $updatePageField ) {
479                        $dbw->update(
480                            'discussiontools_item_pages',
481                            [ $updatePageField => $rev->getId() ],
482                            [ 'itp_id' => $itemPagesRow->itp_id ],
483                            $method
484                        );
485                        if ( $oldestId !== $newestId ) {
486                            // This causes most rows in discussiontools_item_revisions referring to the previously
487                            // oldest/newest revision to be unused, so try re-using them.
488                            $revUpdateRows[ $itemsIds[ $item->getId() ] ] = $itemPagesRow->$updatePageField;
489                        }
490                    }
491                }
492            }
493
494            // Insert or update discussiontools_item_revisions rows, fill in itr_id field values.
495            foreach ( $threadItemSet->getThreadItems() as $item ) {
496                $transcl = $item->getTranscludedFrom();
497                $newOrUpdateRevRow =
498                    [
499                        'itr_itemid_id' => $itemIdsIds[ $item->getId() ],
500                        'itr_revision_id' => $rev->getId(),
501                        'itr_items_id' => $itemsIds[ $item->getId() ],
502                        'itr_parent_id' =>
503                            // This assumes that parent items were processed first
504                            $item->getParent() ? $itemRevisionsIds[ $item->getParent()->getId() ] : null,
505                        'itr_transcludedfrom' =>
506                            $transcl === false ? null : (
507                                $transcl === true ? 0 :
508                                    $this->pageStore->getPageByText( $transcl )->getId()
509                            ),
510                        'itr_level' => $item->getLevel(),
511                    ] +
512                    ( $item instanceof HeadingItem ? [
513                        'itr_headinglevel' => $item->isPlaceholderHeading() ? null : $item->getHeadingLevel(),
514                    ] : [] );
515
516                $itemRevisionsId = $dbw->newSelectQueryBuilder()
517                    ->from( 'discussiontools_item_revisions' )
518                    ->field( 'itr_id' )
519                    ->where( [
520                        'itr_itemid_id' => $itemIdsIds[ $item->getId() ],
521                        'itr_items_id' => $itemsIds[ $item->getId() ],
522                        'itr_revision_id' => $rev->getId(),
523                    ] )
524                    ->caller( $method )
525                    ->fetchField();
526                if ( $itemRevisionsId === false ) {
527                    $itemRevisionsUpdateId = null;
528                    if ( isset( $revUpdateRows[ $itemsIds[ $item->getId() ] ] ) ) {
529                        $itemRevisionsUpdateId = $dbw->newSelectQueryBuilder()
530                            ->from( 'discussiontools_item_revisions' )
531                            ->field( 'itr_id' )
532                            ->where( [
533                                'itr_revision_id' => $revUpdateRows[ $itemsIds[ $item->getId() ] ],
534                                // We only keep up to 2 discussiontools_item_revisions rows with the same
535                                // (itr_itemid_id, itr_items_id) pair, for the oldest and newest revision known.
536                                // Here we find any rows we don't want to keep and re-use them.
537                                'itr_itemid_id' => $itemIdsIds[ $item->getId() ],
538                                'itr_items_id' => $itemsIds[ $item->getId() ],
539                            ] )
540                            ->caller( $method )
541                            ->fetchField();
542                        // The row to re-use may not be found if it has a different itr_itemid_id than the row
543                        // we want to add.
544                    }
545                    if ( $itemRevisionsUpdateId ) {
546                        $dbw->update(
547                            'discussiontools_item_revisions',
548                            $newOrUpdateRevRow,
549                            [ 'itr_id' => $itemRevisionsUpdateId ],
550                            $method
551                        );
552                        $itemRevisionsId = $itemRevisionsUpdateId;
553                    } else {
554                        $dbw->insert(
555                            'discussiontools_item_revisions',
556                            $newOrUpdateRevRow,
557                            $method
558                        );
559                        $itemRevisionsId = $dbw->insertId();
560                    }
561                    $didInsert = true;
562                }
563
564                $itemRevisionsIds[ $item->getId() ] = $itemRevisionsId;
565            }
566        }, $dbw::ATOMIC_CANCELABLE );
567
568        return $didInsert;
569    }
570}