Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 984
0.00% covered (danger)
0.00%
0 / 85
CRAP
0.00% covered (danger)
0.00%
0 / 1
Thread
0.00% covered (danger)
0.00%
0 / 984
0.00% covered (danger)
0.00%
0 / 85
75900
0.00% covered (danger)
0.00%
0 / 1
 isHistorical
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 insert
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 setRoot
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setRootId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 commitRevision
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 logChange
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
90
 updateEditedness
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
 save
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getRow
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
 author
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 delete
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 undelete
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 moveToPage
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 leaveTrace
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 replyCount
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 incrementReplyCount
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 decrementReplyCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newFromRow
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
132
 bulkLoad
0.00% covered (danger)
0.00%
0 / 121
0.00% covered (danger)
0.00%
0 / 1
702
 loadOriginalAuthorFromRevision
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 recursiveGetReplyCount
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 doLazyUpdates
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 1
650
 addReply
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 removeReply
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 checkReplies
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 replies
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 setSuperthread
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 superthread
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 hasSuperthread
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 topmostThread
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 setAncestor
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 fixMissingAncestor
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 isTopmostThread
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 setArticle
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 touch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 article
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 id
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ancestorId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 root
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 editedness
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 summary
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 hasSummary
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSummary
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 title
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 splitIncrementFromSubject
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 subject
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formattedSubject
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSubject
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 hasDistinctSubject
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 subthreads
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 modified
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 created
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 type
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAnchorName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 updateHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setAuthor
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 loadAllData
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 __sleep
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 __wakeup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dieIfHistorical
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 rootRevision
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 sortkey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSortKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 replyWithId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 createdSortCallback
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 split
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 moveToParent
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 recursiveSet
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 validateSubject
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 canUserReply
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 canUserPost
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 canUserCreateThreads
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 signature
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSignature
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 editors
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 setEditors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addEditor
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getReactions
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 addReaction
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 deleteReaction
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisionQueryInfo
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3use MediaWiki\Context\RequestContext;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Parser\Sanitizer;
6use MediaWiki\Permissions\PermissionManager;
7use MediaWiki\Title\Title;
8use MediaWiki\User\User;
9use MediaWiki\User\UserIdentity;
10use Wikimedia\Rdbms\SelectQueryBuilder;
11
12class Thread {
13    /* SCHEMA changes must be reflected here. */
14
15    /* ID references to other objects that are loaded on demand: */
16    /** @var int|null */
17    protected $rootId;
18    /** @var int|null */
19    protected $articleId;
20    /** @var int|null */
21    protected $summaryId;
22    /** @var int|null */
23    protected $ancestorId;
24    /** @var int|null */
25    protected $parentId;
26
27    /* Actual objects loaded on demand from the above when accessors are called: */
28    /** @var Article|null */
29    protected $root;
30    /** @var Article|null */
31    protected $article;
32    /** @var Article|null */
33    protected $summary;
34    /** @var self|null */
35    protected $superthread;
36    /** @var self|null */
37    protected $ancestor;
38
39    /** @var int|null namespace of Subject page of the talkpage we're attached to */
40    protected $articleNamespace;
41    /** @var string|null Subject page of the talkpage we're attached to: */
42    protected $articleTitle;
43
44    /** @var string Timestamp */
45    protected $modified;
46    /** @var string Timestamp */
47    protected $created;
48    /** @var string Timestamp */
49    protected $sortkey;
50
51    /** @var int */
52    protected $id;
53    /** @var int|null */
54    protected $type;
55    /** @var string|null */
56    protected $subject;
57    /** @var int */
58    protected $authorId;
59    /** @var string|null */
60    protected $authorName;
61    /** @var string|null */
62    protected $signature;
63    /** @var int */
64    protected $replyCount;
65
66    /** @var bool|null */
67    protected $allDataLoaded;
68
69    /** @var bool */
70    protected $isHistorical = false;
71
72    /** @var int|null */
73    protected $rootRevision;
74
75    /** @var int Flag about who has edited or replied to this thread. */
76    public $editedness;
77    /** @var string[]|null */
78    protected $editors = null;
79
80    /** @var Thread[]|null */
81    protected $replies;
82    /** @var array[]|null */
83    protected $reactions;
84
85    /** @var self|null */
86    public $dbVersion; // A copy of the thread as it exists in the database.
87    /** @var ThreadRevision */
88    public $threadRevision;
89
90    /** @var Title[] */
91    public static $titleCacheById = [];
92    /** @var self[][] */
93    public static $replyCacheById = [];
94    /** @var Article[] */
95    public static $articleCacheById = [];
96    /** @var array[][] */
97    public static $reactionCacheById = [];
98
99    /** @var int[] */
100    public static $VALID_TYPES = [
101        Threads::TYPE_NORMAL, Threads::TYPE_MOVED, Threads::TYPE_DELETED ];
102
103    public function isHistorical() {
104        return $this->isHistorical;
105    }
106
107    public static function create(
108        $root,
109        Article $article,
110        User $user,
111        ?self $superthread,
112        $type = Threads::TYPE_NORMAL,
113        $subject = '',
114        $summary = '',
115        $bump = null,
116        $signature = null
117    ) {
118        $thread = new self( null );
119
120        if ( !in_array( $type, self::$VALID_TYPES ) ) {
121            throw new UnexpectedValueException( __METHOD__ . ": invalid change type $type." );
122        }
123
124        if ( $superthread ) {
125            $change_type = Threads::CHANGE_REPLY_CREATED;
126        } else {
127            $change_type = Threads::CHANGE_NEW_THREAD;
128        }
129
130        $thread->setAuthor( $user );
131
132        if ( is_object( $root ) ) {
133            $thread->setRoot( $root );
134        } else {
135            $thread->setRootId( $root );
136        }
137
138        $thread->setSuperthread( $superthread );
139        $thread->setArticle( $article );
140        $thread->setSubject( $subject );
141        $thread->setType( $type );
142
143        if ( $signature !== null ) {
144            $thread->setSignature( $signature );
145        }
146
147        $thread->insert();
148
149        if ( $superthread ) {
150            $superthread->addReply( $thread );
151
152            $superthread->commitRevision( $change_type, $user, $thread, $summary, $bump );
153        } else {
154            ThreadRevision::create( $thread, $change_type, $user );
155        }
156
157        // Create talk page
158        Threads::createTalkpageIfNeeded( $article->getPage() );
159
160        // Notifications
161        NewMessages::writeMessageStateForUpdatedThread( $thread, $change_type, $user );
162
163        $services = MediaWikiServices::getInstance();
164        $userOptionsLookup = $services->getUserOptionsLookup();
165        $watchlistManager = $services->getWatchlistManager();
166        if ( $userOptionsLookup->getOption( $user, 'lqt-watch-threads', false ) ) {
167            $watchlistManager->addWatch( $user, $thread->topmostThread()->root()->getTitle() );
168        }
169
170        return $thread;
171    }
172
173    public function insert() {
174        $this->dieIfHistorical();
175
176        if ( $this->id() ) {
177            throw new LogicException( "Attempt to insert a thread that already exists." );
178        }
179
180        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
181
182        $row = $this->getRow();
183
184        $dbw->newInsertQueryBuilder()
185            ->insertInto( 'thread' )
186            ->row( $row )
187            ->caller( __METHOD__ )
188            ->execute();
189        $this->id = $dbw->insertId();
190
191        // Touch the root
192        if ( $this->root() ) {
193            $this->root()->getTitle()->invalidateCache();
194        }
195
196        // Touch the talk page, too.
197        $this->getTitle()->invalidateCache();
198
199        $this->dbVersion = clone $this;
200        $this->dbVersion->dbVersion = null;
201    }
202
203    /**
204     * @param Article $article
205     */
206    public function setRoot( $article ) {
207        $this->rootId = $article->getPage()->getId();
208        $this->root = $article;
209
210        if ( $article->getTitle()->getNamespace() != NS_LQT_THREAD ) {
211            throw new LogicException( "Attempt to set thread root to a non-Thread page" );
212        }
213    }
214
215    public function setRootId( $article ) {
216        $this->rootId = $article;
217        $this->root = null;
218    }
219
220    /**
221     * @param int $change_type
222     * @param User $user
223     * @param self|null $change_object
224     * @param string $reason
225     * @param bool|null $bump
226     *
227     * @throws Exception
228     */
229    public function commitRevision(
230        $change_type,
231        User $user,
232        $change_object = null,
233        $reason = "",
234        $bump = null
235    ) {
236        $this->dieIfHistorical();
237
238        global $wgThreadActionsNoBump;
239        if ( $bump === null ) {
240            $bump = !in_array( $change_type, $wgThreadActionsNoBump );
241        }
242        if ( $bump ) {
243            $this->sortkey = wfTimestamp( TS_MW );
244        }
245
246        $original = $this->dbVersion;
247        if ( $original->signature() != $this->signature() ) {
248            $this->logChange(
249                Threads::CHANGE_EDITED_SIGNATURE,
250                $original,
251                null,
252                $reason
253            );
254        }
255
256        $this->modified = wfTimestampNow();
257        $this->updateEditedness( $change_type, $user );
258        $this->save( __METHOD__ . "/" . wfGetCaller() );
259
260        $topmost = $this->topmostThread();
261        $topmost->modified = wfTimestampNow();
262        if ( $bump ) {
263            $topmost->setSortKey( wfTimestamp( TS_MW ) );
264        }
265        $topmost->save();
266
267        ThreadRevision::create( $this, $change_type, $user, $change_object, $reason );
268        $this->logChange( $change_type, $original, $change_object, $reason );
269
270        if ( $change_type == Threads::CHANGE_EDITED_ROOT ) {
271            NewMessages::writeMessageStateForUpdatedThread( $this, $change_type, $user );
272        }
273    }
274
275    /**
276     * @param int $change_type
277     * @param self $original
278     * @param self|null $change_object
279     * @param string|null $reason
280     */
281    public function logChange(
282        $change_type,
283        $original,
284        $change_object = null,
285        $reason = ''
286    ) {
287        $log = new LogPage( 'liquidthreads' );
288        $user = $this->author();
289
290        if ( $reason === null ) {
291            $reason = '';
292        }
293
294        switch ( $change_type ) {
295            case Threads::CHANGE_MOVED_TALKPAGE:
296                $log->addEntry(
297                    'move',
298                    $this->title(),
299                    $reason,
300                    [ $original->getTitle(), $this->getTitle() ],
301                    $user
302                );
303                break;
304            case Threads::CHANGE_SPLIT:
305                $log->addEntry(
306                    'split',
307                    $this->title(),
308                    $reason,
309                    [ $this->subject(), $original->superthread()->title() ],
310                    $user
311                );
312                break;
313            case Threads::CHANGE_EDITED_SUBJECT:
314                $log->addEntry(
315                    'subjectedit',
316                    $this->title(),
317                    $reason,
318                    [ $original->subject(), $this->subject() ],
319                    $user
320                );
321                break;
322            case Threads::CHANGE_MERGED_TO:
323                $oldParent = $change_object->dbVersion->isTopmostThread()
324                        ? ''
325                        : $change_object->dbVersion->superthread()->title();
326
327                $log->addEntry(
328                    'merge',
329                    $this->title(),
330                    $reason,
331                    [ $oldParent, $change_object->superthread()->title() ],
332                    $user
333                );
334                break;
335            case Threads::CHANGE_ADJUSTED_SORTKEY:
336                $log->addEntry(
337                    'resort',
338                    $this->title(),
339                    $reason,
340                    [ $original->sortkey(), $this->sortkey() ],
341                    $user
342                );
343                break;
344            case Threads::CHANGE_EDITED_SIGNATURE:
345                $log->addEntry(
346                    'signatureedit',
347                    $this->title(),
348                    $reason,
349                    [ $original->signature(), $this->signature() ],
350                    $user
351                );
352                break;
353        }
354    }
355
356    private function updateEditedness( $change_type, User $user ) {
357        if ( $change_type == Threads::CHANGE_REPLY_CREATED
358                && $this->editedness == Threads::EDITED_NEVER ) {
359            $this->editedness = Threads::EDITED_HAS_REPLY;
360        } elseif ( $change_type == Threads::CHANGE_EDITED_ROOT ) {
361            $originalAuthor = $this->author();
362
363            if ( ( $user->getId() == 0 && $originalAuthor->getName() != $user->getName() )
364                    || $user->getId() != $originalAuthor->getId() ) {
365                $this->editedness = Threads::EDITED_BY_OTHERS;
366            } elseif ( $this->editedness == Threads::EDITED_HAS_REPLY ) {
367                $this->editedness = Threads::EDITED_BY_AUTHOR;
368            }
369        }
370    }
371
372    /**
373     * Unless you know what you're doing, you want commitRevision
374     * @param string|null $fname
375     */
376    public function save( $fname = null ) {
377        $this->dieIfHistorical();
378
379        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
380
381        if ( !$fname ) {
382            $fname = __METHOD__ . "/" . wfGetCaller();
383        } else {
384            $fname = __METHOD__ . "/" . $fname;
385        }
386
387        $dbw->newUpdateQueryBuilder()
388            ->update( 'thread' )
389            ->set( $this->getRow() )
390            ->where( [ 'thread_id' => $this->id, ] )
391            ->caller( $fname )
392            ->execute();
393
394        // Touch the root
395        if ( $this->root() ) {
396            $this->root()->getTitle()->invalidateCache();
397        }
398
399        // Touch the talk page, too.
400        $this->getTitle()->invalidateCache();
401
402        $this->dbVersion = clone $this;
403        $this->dbVersion->dbVersion = null;
404    }
405
406    public function getRow() {
407        $id = $this->id();
408
409        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
410
411        // If there's no root, bail out with an error message
412        if ( !$this->rootId && !( $this->type & Threads::TYPE_DELETED ) ) {
413            throw new LogicException( "Non-deleted thread saved with empty root ID" );
414        }
415
416        if ( $this->replyCount < -1 ) {
417            wfWarn(
418                "Saving thread $id with negative reply count {$this->replyCount} " .
419                    wfGetAllCallers()
420            );
421            $this->replyCount = -1;
422        }
423
424        $contLang = MediaWikiServices::getInstance()->getContentLanguage();
425        // Reflect schema changes here.
426        $row = [
427            'thread_root' => $this->rootId,
428            'thread_parent' => $this->parentId,
429            'thread_article_namespace' => $this->articleNamespace,
430            'thread_article_title' => $this->articleTitle,
431            'thread_article_id' => $this->articleId,
432            'thread_modified' => $dbw->timestamp( $this->modified ),
433            'thread_created' => $dbw->timestamp( $this->created ),
434            'thread_ancestor' => $this->ancestorId,
435            'thread_type' => $this->type,
436            'thread_subject' => $this->subject,
437            'thread_author_id' => $this->authorId,
438            'thread_author_name' => $this->authorName,
439            'thread_summary_page' => $this->summaryId,
440            'thread_editedness' => $this->editedness,
441            'thread_sortkey' => $this->sortkey,
442            'thread_replies' => $this->replyCount,
443            'thread_signature' => $contLang->truncateForDatabase( $this->signature, 255, '' ),
444        ];
445        if ( $id ) {
446            $row['thread_id'] = $id;
447        }
448
449        return $row;
450    }
451
452    public function author() {
453        if ( $this->authorId ) {
454            return User::newFromId( $this->authorId );
455        } else {
456            // Do NOT validate username. If the user did it, they did it.
457            return User::newFromName( $this->authorName, false /* no validation */ );
458        }
459    }
460
461    public function delete( $reason, $commit = true ) {
462        if ( $this->type == Threads::TYPE_DELETED ) {
463            return;
464        }
465
466        $this->type = Threads::TYPE_DELETED;
467        $user = RequestContext::getMain()->getUser(); // Need to inject
468
469        if ( $commit ) {
470            $this->commitRevision( Threads::CHANGE_DELETED, $user, $this, $reason );
471        } else {
472            $this->save( __METHOD__ );
473        }
474        /* Mark thread as read by all users, or we get blank thingies in New Messages. */
475
476        $this->dieIfHistorical();
477
478        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
479
480        $dbw->newDeleteQueryBuilder()
481            ->deleteFrom( 'user_message_state' )
482            ->where( [ 'ums_thread' => $this->id() ] )
483            ->caller( __METHOD__ )
484            ->execute();
485
486        // Fix reply count.
487        $t = $this->superthread();
488
489        if ( $t ) {
490            $t->decrementReplyCount( 1 + $this->replyCount() );
491            $t->save();
492        }
493    }
494
495    public function undelete( $reason ) {
496        $this->type = Threads::TYPE_NORMAL;
497        $user = RequestContext::getMain()->getUser(); // Need to inject
498        $this->commitRevision( Threads::CHANGE_UNDELETED, $user, $this, $reason );
499
500        // Fix reply count.
501        $t = $this->superthread();
502        if ( $t ) {
503            $t->incrementReplyCount( 1 );
504            $t->save();
505        }
506    }
507
508    public function moveToPage( $title, $reason, $leave_trace, User $user ) {
509        if ( !$this->isTopmostThread() ) {
510            throw new LogicException( "Attempt to move non-toplevel thread to another page" );
511        }
512
513        $this->dieIfHistorical();
514
515        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
516
517        $oldTitle = $this->getTitle();
518        $newTitle = $title;
519
520        $new_articleNamespace = $title->getNamespace();
521        $new_articleTitle = $title->getDBkey();
522        $new_articleID = $title->getArticleID();
523
524        if ( !$new_articleID ) {
525            $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $newTitle );
526            Threads::createTalkpageIfNeeded( $page );
527            $new_articleID = $page->getId();
528        }
529
530        // Update on *all* subthreads.
531        $dbw->newUpdateQueryBuilder()
532            ->update( 'thread' )
533            ->set( [
534                'thread_article_namespace' => $new_articleNamespace,
535                'thread_article_title' => $new_articleTitle,
536                'thread_article_id' => $new_articleID,
537            ] )
538            ->where( [ 'thread_ancestor' => $this->id() ] )
539            ->caller( __METHOD__ )
540            ->execute();
541
542        $this->articleNamespace = $new_articleNamespace;
543        $this->articleTitle = $new_articleTitle;
544        $this->articleId = $new_articleID;
545        $this->article = null;
546
547        $this->commitRevision( Threads::CHANGE_MOVED_TALKPAGE, $user, null, $reason );
548
549        // Notifications
550        NewMessages::writeMessageStateForUpdatedThread( $this, $this->type, $user );
551
552        if ( $leave_trace ) {
553            $this->leaveTrace( $reason, $oldTitle, $newTitle, $user );
554        }
555    }
556
557    /**
558     * Drop a note at the source location of a move, noting that a thread was moved from there.
559     *
560     * @param string $reason
561     * @param Title $oldTitle
562     * @param Title $newTitle
563     * @param User $user
564     */
565    public function leaveTrace( $reason, $oldTitle, $newTitle, User $user ) {
566        $this->dieIfHistorical();
567
568        // Create redirect text
569        $mwRedir = \MediaWiki\MediaWikiServices::getInstance()->getMagicWordFactory()->get( 'redirect' );
570        $redirectText = $mwRedir->getSynonym( 0 ) .
571            ' [[' . $this->title()->getPrefixedText() . "]]\n";
572
573        // Make the article edit.
574        $traceTitle = Threads::newThreadTitle( $this->subject(), new Article( $oldTitle, 0 ) );
575        $redirectArticle = new Article( $traceTitle, 0 );
576
577        $redirectArticle->getPage()->doUserEditContent(
578            ContentHandler::makeContent( $redirectText, $traceTitle ),
579            $user,
580            $reason,
581            EDIT_NEW | EDIT_SUPPRESS_RC
582        );
583
584        // Add the trace thread to the tracking table.
585        $thread = self::create(
586            $redirectArticle,
587            new Article( $oldTitle, 0 ),
588            $user,
589            null,
590            Threads::TYPE_MOVED,
591            $this->subject()
592        );
593
594        $thread->setSortKey( $this->sortkey() );
595        $thread->save();
596    }
597
598    /**
599     * Lists total reply count, including replies to replies and such
600     *
601     * @return int
602     */
603    public function replyCount() {
604        // Populate reply count
605        if ( $this->replyCount == -1 ) {
606            if ( $this->isTopmostThread() ) {
607                $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
608
609                $count = $dbr->newSelectQueryBuilder()
610                    ->select( 'COUNT(*)' )
611                    ->from( 'thread' )
612                    ->where( [ 'thread_ancestor' => $this->id() ] )
613                    ->caller( __METHOD__ )
614                    ->fetchField();
615            } else {
616                $count = self::recursiveGetReplyCount( $this );
617            }
618
619            $this->replyCount = $count;
620            $this->save();
621        }
622
623        return $this->replyCount;
624    }
625
626    public function incrementReplyCount( $val = 1 ) {
627        $this->replyCount += $val;
628
629        wfDebug( "Incremented reply count for thread " . $this->id() . " to " .
630            $this->replyCount . "\n" );
631
632        $thread = $this->superthread();
633
634        if ( $thread ) {
635            $thread->incrementReplyCount( $val );
636            wfDebug( "Saving Incremented thread " . $thread->id() .
637                " with reply count " . $thread->replyCount . "\n" );
638            $thread->save();
639        }
640    }
641
642    public function decrementReplyCount( $val = 1 ) {
643        $this->incrementReplyCount( -$val );
644    }
645
646    /**
647     * @param stdClass $row
648     * @return self
649     */
650    public static function newFromRow( $row ) {
651        $id = $row->thread_id;
652
653        if ( isset( Threads::$cache_by_id[$id] ) ) {
654            return Threads::$cache_by_id[$id];
655        }
656
657        return new self( $row );
658    }
659
660    /**
661     * @param stdClass|null $line
662     * @param null $unused
663     */
664    protected function __construct( $line, $unused = null ) {
665        /* SCHEMA changes must be reflected here. */
666
667        if ( $line === null ) { // For Thread::create().
668            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
669            $this->modified = $dbr->timestamp( wfTimestampNow() );
670            $this->created = $dbr->timestamp( wfTimestampNow() );
671            $this->sortkey = wfTimestamp( TS_MW );
672            $this->editedness = Threads::EDITED_NEVER;
673            $this->replyCount = 0;
674            return;
675        }
676
677        $dataLoads = [
678            'thread_id' => 'id',
679            'thread_root' => 'rootId',
680            'thread_article_namespace' => 'articleNamespace',
681            'thread_article_title' => 'articleTitle',
682            'thread_article_id' => 'articleId',
683            'thread_summary_page' => 'summaryId',
684            'thread_ancestor' => 'ancestorId',
685            'thread_parent' => 'parentId',
686            'thread_modified' => 'modified',
687            'thread_created' => 'created',
688            'thread_type' => 'type',
689            'thread_editedness' => 'editedness',
690            'thread_subject' => 'subject',
691            'thread_author_id' => 'authorId',
692            'thread_author_name' => 'authorName',
693            'thread_sortkey' => 'sortkey',
694            'thread_replies' => 'replyCount',
695            'thread_signature' => 'signature',
696        ];
697
698        foreach ( $dataLoads as $db_field => $member_field ) {
699            if ( isset( $line->$db_field ) ) {
700                $this->$member_field = $line->$db_field;
701            }
702        }
703
704        if ( isset( $line->page_namespace ) && isset( $line->page_title ) ) {
705            $root_title = Title::makeTitle( $line->page_namespace, $line->page_title );
706            $this->root = new Article( $root_title, 0 );
707            $this->root->getPage()->loadPageData( $line );
708        } else {
709            if ( isset( self::$titleCacheById[$this->rootId] ) ) {
710                $root_title = self::$titleCacheById[$this->rootId];
711            } else {
712                $root_title = Title::newFromID( $this->rootId );
713            }
714
715            if ( $root_title ) {
716                $this->root = new Article( $root_title, 0 );
717            }
718        }
719
720        Threads::$cache_by_id[$line->thread_id] = $this;
721        if ( $line->thread_parent ) {
722            if ( !isset( self::$replyCacheById[$line->thread_parent] ) ) {
723                self::$replyCacheById[$line->thread_parent] = [];
724            }
725            self::$replyCacheById[$line->thread_parent][$line->thread_id] = $this;
726        }
727
728        try {
729            $this->doLazyUpdates();
730        } catch ( Exception $excep ) {
731            trigger_error( "Exception doing lazy updates: " . $excep->__toString() );
732        }
733
734        $this->dbVersion = clone $this;
735        $this->dbVersion->dbVersion = null;
736    }
737
738    /**
739     * Load a list of threads in bulk, including all subthreads.
740     *
741     * @param stdClass[] $rows
742     * @return self[]
743     */
744    public static function bulkLoad( $rows ) {
745        // Preload subthreads
746        $top_thread_ids = [];
747        $all_thread_rows = $rows;
748        $pageIds = [];
749        $linkBatch = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch();
750        $userIds = [];
751        $loadEditorsFor = [];
752
753        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
754
755        if ( !is_array( self::$replyCacheById ) ) {
756            self::$replyCacheById = [];
757        }
758
759        // Build a list of threads for which to pull replies, and page IDs to pull data for.
760        // Also, pre-initialise the reply cache.
761        foreach ( $rows as $row ) {
762            if ( $row->thread_ancestor ) {
763                $top_thread_ids[] = $row->thread_ancestor;
764            } else {
765                $top_thread_ids[] = $row->thread_id;
766            }
767
768            // Grab page data while we're here.
769            if ( $row->thread_root ) {
770                $pageIds[] = $row->thread_root;
771            }
772            if ( $row->thread_summary_page ) {
773                $pageIds[] = $row->thread_summary_page;
774            }
775            if ( !isset( self::$replyCacheById[$row->thread_id] ) ) {
776                self::$replyCacheById[$row->thread_id] = [];
777            }
778        }
779
780        $all_thread_ids = $top_thread_ids;
781
782        // Pull replies to the threads provided, and as above, pull page IDs to pull data for,
783        // pre-initialise the reply cache, and stash the row object for later use.
784        if ( count( $top_thread_ids ) ) {
785            $res = $dbr->newSelectQueryBuilder()
786                ->select( '*' )
787                ->from( 'thread' )
788                ->where( [
789                    'thread_ancestor' => $top_thread_ids,
790                    $dbr->expr( 'thread_type', '!=', Threads::TYPE_DELETED ),
791                ] )
792                ->caller( __METHOD__ )
793                ->fetchResultSet();
794
795            foreach ( $res as $row ) {
796                // Grab page data while we're here.
797                if ( $row->thread_root ) {
798                    $pageIds[] = $row->thread_root;
799                }
800                if ( $row->thread_summary_page ) {
801                    $pageIds[] = $row->thread_summary_page;
802                }
803                $all_thread_rows[] = $row;
804                $all_thread_ids[$row->thread_id] = $row->thread_id;
805            }
806        }
807
808        // Pull thread reactions
809        if ( count( $all_thread_ids ) ) {
810            $res = $dbr->newSelectQueryBuilder()
811                ->select( '*' )
812                ->from( 'thread_reaction' )
813                ->where( [ 'tr_thread' => $all_thread_ids ] )
814                ->caller( __METHOD__ )
815                ->fetchResultSet();
816
817            foreach ( $res as $row ) {
818                $thread_id = $row->tr_thread;
819                $info = [
820                    'type' => $row->tr_type,
821                    'user-id' => $row->tr_user,
822                    'user-name' => $row->tr_user_text,
823                    'value' => $row->tr_value,
824                ];
825
826                $type = $info['type'];
827                $user = $info['user-name'];
828
829                if ( !isset( self::$reactionCacheById[$thread_id] ) ) {
830                    self::$reactionCacheById[$thread_id] = [];
831                }
832
833                if ( !isset( self::$reactionCacheById[$thread_id][$type] ) ) {
834                    self::$reactionCacheById[$thread_id][$type] = [];
835                }
836
837                self::$reactionCacheById[$thread_id][$type][$user] = $info;
838            }
839        }
840
841        // Preload page data (restrictions, and preload Article object with everything from
842        // the page table. Also, precache the title and article objects for pulling later.
843        $articlesById = [];
844        if ( count( $pageIds ) ) {
845            // Pull restriction info. Needs to come first because otherwise it's done per
846            // page by loadPageData.
847            $restrictionRows = array_fill_keys( $pageIds, [] );
848            $res = $dbr->newSelectQueryBuilder()
849                ->select( '*' )
850                ->from( 'page_restrictions' )
851                ->where( [ 'pr_page' => $pageIds ] )
852                ->caller( __METHOD__ )
853                ->fetchResultSet();
854            foreach ( $res as $row ) {
855                $restrictionRows[$row->pr_page][] = $row;
856            }
857
858            $res = $dbr->newSelectQueryBuilder()
859                ->select( '*' )
860                ->from( 'page' )
861                ->where( [ 'page_id' => $pageIds ] )
862                ->caller( __METHOD__ )
863                ->fetchResultSet();
864
865            $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore();
866            foreach ( $res as $row ) {
867                $t = Title::newFromRow( $row );
868
869                if ( isset( $restrictionRows[$t->getArticleID()] ) ) {
870                    $restrictionStore->loadRestrictionsFromRows( $t, $restrictionRows[$t->getArticleID()] );
871                }
872
873                $article = new Article( $t, 0 );
874                $article->getPage()->loadPageData( $row );
875
876                self::$titleCacheById[$t->getArticleID()] = $t;
877                $articlesById[$article->getPage()->getId()] = $article;
878
879                if ( count( self::$titleCacheById ) > 10000 ) {
880                    self::$titleCacheById = [];
881                }
882            }
883        }
884
885        // For every thread we have a row object for, load a Thread object, add the user and
886        // user talk pages to a link batch, cache the relevant user id/name pair, and
887        // populate the reply cache.
888        foreach ( $all_thread_rows as $row ) {
889            $thread = self::newFromRow( $row );
890
891            if ( isset( $articlesById[$thread->rootId] ) ) {
892                $thread->root = $articlesById[$thread->rootId];
893            }
894
895            // User cache data
896            $t = Title::makeTitleSafe( NS_USER, $row->thread_author_name );
897            $linkBatch->addObj( $t );
898            $t = Title::makeTitleSafe( NS_USER_TALK, $row->thread_author_name );
899            $linkBatch->addObj( $t );
900
901            $userIds[$row->thread_author_id] = true;
902
903            if ( $row->thread_editedness > Threads::EDITED_BY_AUTHOR ) {
904                $loadEditorsFor[$row->thread_root] = $thread;
905                $thread->setEditors( [] );
906            }
907        }
908
909        // Pull list of users who have edited
910        if ( count( $loadEditorsFor ) ) {
911            $revQuery = self::getRevisionQueryInfo();
912            $res = $dbr->newSelectQueryBuilder()
913                ->select( [ 'rev_user_text' => $revQuery['fields']['rev_user_text'], 'rev_page' ] )
914                ->tables( $revQuery['tables'] )
915                ->where( [
916                    'rev_page' => array_keys( $loadEditorsFor ),
917                    $dbr->expr( 'rev_parent_id', '!=', 0 ),
918                ] )
919                ->caller( __METHOD__ )
920                ->joinConds( $revQuery['joins'] )
921                ->fetchResultSet();
922            foreach ( $res as $row ) {
923                $pageid = $row->rev_page;
924                $editor = $row->rev_user_text;
925                $t = $loadEditorsFor[$pageid];
926
927                $t->addEditor( $editor );
928            }
929        }
930
931        // Pull link batch data.
932        $linkBatch->execute();
933
934        $threads = [];
935
936        // Fill and return an array with the threads that were actually requested.
937        foreach ( $rows as $row ) {
938            $threads[$row->thread_id] = Threads::$cache_by_id[$row->thread_id];
939        }
940
941        return $threads;
942    }
943
944    /**
945     * @return User|null the User object representing the author of the first revision
946     * (or null, if the database is screwed up).
947     */
948    public function loadOriginalAuthorFromRevision() {
949        $this->dieIfHistorical();
950
951        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
952
953        $article = $this->root();
954
955        $revQuery = self::getRevisionQueryInfo();
956        $line = $dbr->newSelectQueryBuilder()
957            ->select( [ 'rev_user_text' => $revQuery['fields']['rev_user_text'] ] )
958            ->tables( $revQuery['tables'] )
959            ->where( [ 'rev_page' => $article->getPage()->getId() ] )
960            ->caller( __METHOD__ )
961            ->orderBy( 'rev_timestamp' )
962            ->joinConds( $revQuery['joins'] )
963            ->fetchRow();
964        if ( $line ) {
965            return User::newFromName( $line->rev_user_text, false );
966        } else {
967            return null;
968        }
969    }
970
971    public static function recursiveGetReplyCount( self $thread, $level = 1 ) {
972        if ( $level > 80 ) {
973            return 1;
974        }
975
976        $count = 0;
977
978        foreach ( $thread->replies() as $reply ) {
979            if ( $thread->type != Threads::TYPE_DELETED ) {
980                $count++;
981                $count += self::recursiveGetReplyCount( $reply, $level + 1 );
982            }
983        }
984
985        return $count;
986    }
987
988    /**
989     * Lazy updates done whenever a thread is loaded.
990     * Much easier than running a long-running maintenance script.
991     */
992    public function doLazyUpdates() {
993        if ( $this->isHistorical() ) {
994            return; // Don't do lazy updates on stored historical threads.
995        }
996
997        // This is an invocation guard to avoid infinite recursion when fixing a
998        // missing ancestor.
999        static $doingUpdates = false;
1000        if ( $doingUpdates ) {
1001            return;
1002        }
1003        $doingUpdates = true;
1004
1005        // Fix missing ancestry information.
1006        // (there was a bug where this was not saved properly)
1007        if ( $this->parentId && !$this->ancestorId ) {
1008            $this->fixMissingAncestor();
1009        }
1010
1011        $ancestor = $this->topmostThread();
1012
1013        $set = [];
1014
1015        // Fix missing subject information
1016        // (this information only started to be added later)
1017        if ( !$this->subject && $this->root() ) {
1018            $detectedSubject = $this->root()->getTitle()->getText();
1019            $parts = self::splitIncrementFromSubject( $detectedSubject );
1020
1021            $this->subject = $detectedSubject = $parts[1];
1022
1023            // Update in the DB
1024            $set['thread_subject'] = $detectedSubject;
1025        }
1026
1027        // Fix inconsistent subject information
1028        // (in some intermediate versions this was not updated when the subject was changed)
1029        if ( $this->subject() != $ancestor->subject() ) {
1030            $set['thread_subject'] = $ancestor->subject();
1031
1032            $this->subject = $ancestor->subject();
1033        }
1034
1035        // Fix missing authorship information
1036        // (this information only started to be added later)
1037        if ( !$this->authorName ) {
1038            $author = $this->loadOriginalAuthorFromRevision();
1039
1040            $this->authorId = $author->getId();
1041            $this->authorName = $author->getName();
1042
1043            $set['thread_author_name'] = $this->authorName;
1044            $set['thread_author_id'] = $this->authorId;
1045        }
1046
1047        // Check for article being in subject, not talk namespace.
1048        // If the page is non-LiquidThreads and it's in subject-space, we'll assume it's meant
1049        // to be on the corresponding talk page, but only if the talk-page is a LQT page.
1050        // (Previous versions stored the subject page, for some totally bizarre reason)
1051        // Old versions also sometimes store the thread page for trace threads as the
1052        // article, not as the root.
1053        // Trying not to exacerbate this by moving it to be the 'Thread talk' page.
1054        $articleTitle = $this->getTitle();
1055        global $wgLiquidThreadsMigrate;
1056        if ( !LqtDispatch::isLqtPage( $articleTitle ) && !$articleTitle->isTalkPage() &&
1057            LqtDispatch::isLqtPage( $articleTitle->getTalkPage() ) &&
1058            $articleTitle->getNamespace() != NS_LQT_THREAD &&
1059            $wgLiquidThreadsMigrate
1060        ) {
1061            $newTitle = $articleTitle->getTalkPage();
1062            $newArticle = new Article( $newTitle, 0 );
1063
1064            $set['thread_article_namespace'] = $newTitle->getNamespace();
1065            $set['thread_article_title'] = $newTitle->getDBkey();
1066
1067            $this->articleNamespace = $newTitle->getNamespace();
1068            $this->articleTitle = $newTitle->getDBkey();
1069            $this->articleId = $newTitle->getArticleID();
1070
1071            $this->article = $newArticle;
1072        }
1073
1074        // Check for article corruption from incomplete thread moves.
1075        // (thread moves only updated this on immediate replies, not replies to replies etc)
1076        if ( !$ancestor->getTitle()->equals( $this->getTitle() ) ) {
1077            $title = $ancestor->getTitle();
1078            $set['thread_article_namespace'] = $title->getNamespace();
1079            $set['thread_article_title'] = $title->getDBkey();
1080
1081            $this->articleNamespace = $title->getNamespace();
1082            $this->articleTitle = $title->getDBkey();
1083            $this->articleId = $title->getArticleID();
1084
1085            $this->article = $ancestor->article();
1086        }
1087
1088        // Check for invalid/missing articleId
1089        $articleTitle = null;
1090        $dbTitle = Title::makeTitleSafe( $this->articleNamespace, $this->articleTitle );
1091        if ( $this->articleId && isset( self::$titleCacheById[$this->articleId] ) ) {
1092            // If it corresponds to a title, the article obviously exists.
1093            $articleTitle = self::$titleCacheById[$this->articleId];
1094            $this->article = new Article( $articleTitle, 0 );
1095        } elseif ( $this->articleId ) {
1096            $articleTitle = Title::newFromID( $this->articleId );
1097        }
1098
1099        // If still unfilled, the article ID referred to is no longer valid. Re-fill it
1100        // from the namespace/title pair if an article ID is provided
1101        if ( !$articleTitle && ( $this->articleId != 0 || $dbTitle->getArticleID() != 0 ) ) {
1102            $articleTitle = $dbTitle;
1103            $this->articleId = $articleTitle->getArticleID();
1104            $this->article = new Article( $dbTitle, 0 );
1105
1106            $set['thread_article_id'] = $this->articleId;
1107            wfDebug(
1108                "Unfilled or non-existent thread_article_id, refilling to {$this->articleId}\n"
1109            );
1110
1111            // There are probably problems on the rest of the article, trigger a small update
1112            Threads::synchroniseArticleData( $this->article->getPage(), 100, 'cascade' );
1113        } elseif ( $articleTitle && !$articleTitle->equals( $dbTitle ) ) {
1114            // The page was probably moved and this was probably not updated.
1115            wfDebug(
1116                "Article ID/Title discrepancy, resetting NS/Title to article provided by ID\n"
1117            );
1118            $this->articleNamespace = $articleTitle->getNamespace();
1119            $this->articleTitle = $articleTitle->getDBkey();
1120            $this->article = new Article( $articleTitle, 0 );
1121
1122            $set['thread_article_namespace'] = $articleTitle->getNamespace();
1123            $set['thread_article_title'] = $articleTitle->getDBkey();
1124
1125            // There are probably problems on the rest of the article, trigger a small update
1126            Threads::synchroniseArticleData( $this->article->getPage(), 100, 'cascade' );
1127        }
1128
1129        // Check for unfilled signature field. This field hasn't existed until
1130        // recently.
1131        if ( $this->signature === null ) {
1132            // Grab our signature.
1133            $sig = LqtView::getUserSignature( $this->author() );
1134            $contLang = MediaWikiServices::getInstance()->getContentLanguage();
1135            $set['thread_signature'] = $contLang->truncateForDatabase( $sig, 255, '' );
1136            $this->setSignature( $sig );
1137        }
1138
1139        if ( count( $set ) ) {
1140            $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
1141
1142            $dbw->newUpdateQueryBuilder()
1143                ->update( 'thread' )
1144                ->set( $set )
1145                ->where( [ 'thread_id' => $this->id() ] )
1146                ->caller( __METHOD__ )
1147                ->execute();
1148        }
1149
1150        // Done
1151        $doingUpdates = false;
1152    }
1153
1154    public function addReply( self $thread ) {
1155        $thread->setSuperThread( $this );
1156
1157        if ( is_array( $this->replies ) ) {
1158            $this->replies[$thread->id()] = $thread;
1159        } else {
1160            $this->replies();
1161            // @phan-suppress-next-line PhanTypeArraySuspicious $replies set by replies()
1162            $this->replies[$thread->id()] = $thread;
1163        }
1164
1165        // Increment reply count.
1166        $this->incrementReplyCount( $thread->replyCount() + 1 );
1167    }
1168
1169    private function removeReply( self $thread ) {
1170        $thread = $thread->id();
1171
1172        $this->replies();
1173
1174        unset( $this->replies[$thread] );
1175
1176        // Also, decrement the reply count.
1177        $threadObj = Threads::withId( $thread );
1178        $this->decrementReplyCount( 1 + $threadObj->replyCount() );
1179    }
1180
1181    private function checkReplies( $replies ) {
1182        // Fixes a bug where some history pages were not working, before
1183        // superthread was properly instance-cached.
1184        if ( $this->isHistorical() ) {
1185            return;
1186        }
1187        foreach ( $replies as $reply ) {
1188            if ( !$reply->hasSuperthread() ) {
1189                throw new RuntimeException( "Post " . $this->id() .
1190                " has contaminated reply " . $reply->id() .
1191                ". Found no superthread." );
1192            }
1193
1194            if ( $reply->superthread()->id() != $this->id() ) {
1195                throw new RuntimeException( "Post " . $this->id() .
1196                " has contaminated reply " . $reply->id() .
1197                ". Expected " . $this->id() . ", got " .
1198                $reply->superthread()->id() );
1199            }
1200        }
1201    }
1202
1203    /**
1204     * @return self[]
1205     */
1206    public function replies() {
1207        if ( !$this->id() ) {
1208            return [];
1209        }
1210
1211        if ( $this->replies !== null ) {
1212            $this->checkReplies( $this->replies );
1213            return $this->replies;
1214        }
1215
1216        $this->dieIfHistorical();
1217
1218        // Check cache
1219        if ( isset( self::$replyCacheById[$this->id()] ) ) {
1220            $this->replies = self::$replyCacheById[$this->id()];
1221            return $this->replies;
1222        }
1223
1224        $this->replies = [];
1225
1226        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1227
1228        $res = $dbr->newSelectQueryBuilder()
1229            ->select( '*' )
1230            ->from( 'thread' )
1231            ->where( [
1232                'thread_parent' => $this->id(),
1233                $dbr->expr( 'thread_type', '!=', Threads::TYPE_DELETED ),
1234            ] )
1235            ->caller( __METHOD__ )
1236            ->fetchResultSet();
1237
1238        $rows = [];
1239        foreach ( $res as $row ) {
1240            $rows[] = $row;
1241        }
1242
1243        $this->replies = self::bulkLoad( $rows );
1244
1245        $this->checkReplies( $this->replies );
1246
1247        return $this->replies;
1248    }
1249
1250    public function setSuperthread( ?self $thread ) {
1251        if ( $thread == null ) {
1252            $this->parentId = null;
1253            $this->ancestorId = 0;
1254            return;
1255        }
1256
1257        $this->parentId = $thread->id();
1258        $this->superthread = $thread;
1259
1260        if ( $thread->isTopmostThread() ) {
1261            $this->ancestorId = $thread->id();
1262            $this->ancestor = $thread;
1263        } else {
1264            $this->ancestorId = $thread->ancestorId();
1265            $this->ancestor = $thread->topmostThread();
1266        }
1267    }
1268
1269    public function superthread() {
1270        if ( !$this->hasSuperthread() ) {
1271            return null;
1272        } elseif ( $this->superthread ) {
1273            return $this->superthread;
1274        } else {
1275            $this->dieIfHistorical();
1276            $this->superthread = Threads::withId( $this->parentId );
1277            return $this->superthread;
1278        }
1279    }
1280
1281    public function hasSuperthread() {
1282        return !$this->isTopmostThread();
1283    }
1284
1285    /**
1286     * @return self
1287     */
1288    public function topmostThread() {
1289        if ( $this->isTopmostThread() ) {
1290            $this->ancestor = $this;
1291            return $this->ancestor;
1292        } elseif ( $this->ancestor ) {
1293            return $this->ancestor;
1294        } else {
1295            $this->dieIfHistorical();
1296
1297            $thread = Threads::withId( $this->ancestorId );
1298
1299            if ( !$thread ) {
1300                $thread = $this->fixMissingAncestor();
1301            }
1302
1303            $this->ancestor = $thread;
1304
1305            return $thread;
1306        }
1307    }
1308
1309    /**
1310     * @param self|int $newAncestor
1311     */
1312    public function setAncestor( $newAncestor ) {
1313        if ( is_object( $newAncestor ) ) {
1314            $this->ancestorId = $newAncestor->id();
1315        } else {
1316            $this->ancestorId = $newAncestor;
1317        }
1318    }
1319
1320    /**
1321     * Due to a bug in earlier versions, the topmost thread sometimes isn't there.
1322     * Fix the corruption by repeatedly grabbing the parent until we hit the topmost thread.
1323     *
1324     * @return self
1325     */
1326    public function fixMissingAncestor() {
1327        $thread = $this;
1328
1329        $this->dieIfHistorical();
1330
1331        while ( !$thread->isTopmostThread() ) {
1332            $thread = $thread->superthread();
1333        }
1334
1335        $this->ancestorId = $thread->id();
1336
1337        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
1338        $dbw->newUpdateQueryBuilder()
1339            ->update( 'thread' )
1340            ->set( [ 'thread_ancestor' => $thread->id() ] )
1341            ->where( [ 'thread_id' => $this->id() ] )
1342            ->caller( __METHOD__ )
1343            ->execute();
1344
1345        // @phan-suppress-next-line PhanTypeMismatchReturnNullable Would crash above if null
1346        return $thread;
1347    }
1348
1349    public function isTopmostThread() {
1350        return $this->ancestorId == $this->id ||
1351                $this->parentId == 0;
1352    }
1353
1354    public function setArticle( Article $a ) {
1355        $this->articleId = $a->getPage()->getId();
1356        $this->articleNamespace = $a->getTitle()->getNamespace();
1357        $this->articleTitle = $a->getTitle()->getDBkey();
1358        $this->touch();
1359    }
1360
1361    public function touch() {
1362        // Nothing here yet
1363    }
1364
1365    /**
1366     * @return Article
1367     */
1368    public function article() {
1369        if ( $this->article ) {
1370            return $this->article;
1371        }
1372
1373        if ( $this->articleId !== null ) {
1374            if ( isset( self::$articleCacheById[$this->articleId] ) ) {
1375                return self::$articleCacheById[$this->articleId];
1376            }
1377
1378            if ( isset( self::$titleCacheById[$this->articleId] ) ) {
1379                $title = self::$titleCacheById[$this->articleId];
1380            } else {
1381                $title = Title::newFromID( $this->articleId );
1382            }
1383
1384            if ( $title ) {
1385                $article = new Article( $title, 0 );
1386                self::$articleCacheById[$this->articleId] = $article;
1387            }
1388        }
1389
1390        if ( isset( $article ) && $article->getPage()->exists() ) {
1391            $this->article = $article;
1392            return $article;
1393        } else {
1394            $title = Title::makeTitle( $this->articleNamespace, $this->articleTitle );
1395            return new Article( $title, 0 );
1396        }
1397    }
1398
1399    /**
1400     * @return int
1401     */
1402    public function id() {
1403        return $this->id;
1404    }
1405
1406    /**
1407     * @return int|null
1408     */
1409    public function ancestorId() {
1410        return $this->ancestorId;
1411    }
1412
1413    /**
1414     * The 'root' is the page in the Thread namespace corresponding to this thread.
1415     *
1416     * @return Article|null
1417     */
1418    public function root() {
1419        if ( !$this->rootId ) {
1420            return null;
1421        }
1422        if ( !$this->root ) {
1423            if ( isset( self::$articleCacheById[$this->rootId] ) ) {
1424                $this->root = self::$articleCacheById[$this->rootId];
1425                return $this->root;
1426            }
1427
1428            if ( isset( self::$titleCacheById[$this->rootId] ) ) {
1429                $title = self::$titleCacheById[$this->rootId];
1430            } else {
1431                $title = Title::newFromID( $this->rootId );
1432            }
1433
1434            if ( !$title && $this->type() != Threads::TYPE_DELETED ) {
1435                if ( !$this->isHistorical() ) {
1436                    $this->delete( '', false /* !commit */ );
1437                } else {
1438                    $this->type = Threads::TYPE_DELETED;
1439                }
1440            }
1441
1442            if ( !$title ) {
1443                return null;
1444            }
1445
1446            $this->root = new Article( $title, 0 );
1447        }
1448        return $this->root;
1449    }
1450
1451    /**
1452     * @return int
1453     */
1454    public function editedness() {
1455        return $this->editedness;
1456    }
1457
1458    /**
1459     * @return Article|null
1460     */
1461    public function summary() {
1462        if ( !$this->summaryId ) {
1463            return null;
1464        }
1465
1466        if ( !$this->summary ) {
1467            $title = Title::newFromID( $this->summaryId );
1468
1469            if ( !$title ) {
1470                wfDebug( __METHOD__ . ": supposed summary doesn't exist" );
1471                $this->summaryId = null;
1472                return null;
1473            }
1474
1475            $this->summary = new Article( $title, 0 );
1476        }
1477
1478        return $this->summary;
1479    }
1480
1481    public function hasSummary() {
1482        return $this->summaryId != null;
1483    }
1484
1485    /**
1486     * @param Article $post
1487     */
1488    public function setSummary( $post ) {
1489        // Weird -- this was setting $this->summary to NULL before I changed it.
1490        // If there was some reason why, please tell me! -- Andrew
1491        $this->summary = $post;
1492        $this->summaryId = $post->getPage()->getId();
1493    }
1494
1495    /**
1496     * @return Title|null
1497     */
1498    public function title() {
1499        if ( is_object( $this->root() ) ) {
1500            return $this->root()->getTitle();
1501        } else {
1502            // wfWarn( "Thread ".$this->id()." has no title." );
1503            return null;
1504        }
1505    }
1506
1507    public static function splitIncrementFromSubject( $subject_string ) {
1508        preg_match( '/^(.*) \((\d+)\)$/', $subject_string, $matches );
1509        if ( count( $matches ) != 3 ) {
1510            throw new LogicException(
1511                __METHOD__ . ": thread subject has no increment: " . $subject_string
1512            );
1513        } else {
1514            return $matches;
1515        }
1516    }
1517
1518    public function subject() {
1519        return $this->subject;
1520    }
1521
1522    public function formattedSubject() {
1523        return LqtView::formatSubject( $this->subject() );
1524    }
1525
1526    public function setSubject( $subject ) {
1527        $this->subject = $subject;
1528
1529        foreach ( $this->replies() as $reply ) {
1530            $reply->setSubject( $subject );
1531        }
1532    }
1533
1534    /**
1535     * Currently equivalent to isTopmostThread.
1536     *
1537     * @return bool
1538     */
1539    public function hasDistinctSubject() {
1540        return $this->isTopmostThread();
1541    }
1542
1543    /**
1544     * Synonym for replies()
1545     *
1546     * @return self[]
1547     */
1548    public function subthreads() {
1549        return $this->replies();
1550    }
1551
1552    public function modified() {
1553        return $this->modified;
1554    }
1555
1556    public function created() {
1557        return $this->created;
1558    }
1559
1560    public function type() {
1561        return $this->type;
1562    }
1563
1564    public function setType( $t ) {
1565        $this->type = $t;
1566    }
1567
1568    public function getAnchorName() {
1569        $wantedId = $this->subject() . "_{$this->id()}";
1570        return Sanitizer::escapeIdForLink( $wantedId );
1571    }
1572
1573    public function updateHistory() {
1574    }
1575
1576    public function setAuthor( UserIdentity $user ) {
1577        $this->authorId = $user->getId();
1578        $this->authorName = $user->getName();
1579    }
1580
1581    /**
1582     * Load all lazy-loaded data in prep for (e.g.) serialization.
1583     */
1584    public function loadAllData() {
1585        // Make sure superthread and topmost thread are loaded.
1586        $this->superthread();
1587        $this->topmostThread();
1588
1589        // Make sure replies, and all the data therein, is loaded.
1590        foreach ( $this->replies() as $reply ) {
1591            $reply->loadAllData();
1592        }
1593    }
1594
1595    /**
1596     * On serialization, load all data because it will be different in the DB when we wake up.
1597     *
1598     * @return string[]
1599     */
1600    public function __sleep() {
1601        $this->loadAllData();
1602
1603        $fields = array_keys( get_object_vars( $this ) );
1604
1605        // Filter out article objects, there be dragons (or unserialization problems)
1606        $fields = array_diff( $fields, [ 'root', 'article', 'summary', 'sleeping',
1607            'dbVersion' ] );
1608
1609        return $fields;
1610    }
1611
1612    public function __wakeup() {
1613        // Mark as historical.
1614        $this->isHistorical = true;
1615    }
1616
1617    /**
1618     * This is a safety valve that makes sure that the DB is NEVER touched by a historical thread
1619     * (even for reading, because the data will be out of date).
1620     *
1621     * @throws Exception
1622     */
1623    public function dieIfHistorical() {
1624        if ( $this->isHistorical() ) {
1625            throw new LogicException( "Attempted write or DB operation on historical thread" );
1626        }
1627    }
1628
1629    /**
1630     * @return int|null
1631     */
1632    public function rootRevision() {
1633        if ( !$this->isHistorical() ||
1634            !isset( $this->topmostThread()->threadRevision ) ||
1635            !$this->root()
1636        ) {
1637            return null;
1638        }
1639
1640        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1641
1642        $revision = $this->topmostThread()->threadRevision;
1643        $timestamp = $dbr->timestamp( $revision->getTimestamp() );
1644
1645        $row = $dbr->newSelectQueryBuilder()
1646            ->select( '*' )
1647            ->from( 'revision' )
1648            ->join( 'page', null, 'rev_page=page_id' )
1649            ->where( [
1650                $dbr->expr( 'rev_timestamp', '<=', $timestamp ),
1651                'page_namespace' => $this->root()->getTitle()->getNamespace(),
1652                'page_title' => $this->root()->getTitle()->getDBkey(),
1653            ] )
1654            ->caller( __METHOD__ )
1655            ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_DESC )
1656            ->fetchRow();
1657
1658        return $row->rev_id;
1659    }
1660
1661    public function sortkey() {
1662        return $this->sortkey;
1663    }
1664
1665    public function setSortKey( $k = null ) {
1666        if ( $k === null ) {
1667            $k = wfTimestamp( TS_MW );
1668        }
1669
1670        $this->sortkey = $k;
1671    }
1672
1673    public function replyWithId( $id ) {
1674        if ( $this->id() == $id ) {
1675            return $this;
1676        }
1677
1678        foreach ( $this->replies() as $reply ) {
1679            $obj = $reply->replyWithId( $id );
1680            if ( $obj ) {
1681                return $obj;
1682            }
1683        }
1684
1685        return null;
1686    }
1687
1688    public static function createdSortCallback( self $a, self $b ) {
1689        $a = $a->created();
1690        $b = $b->created();
1691
1692        if ( $a == $b ) {
1693            return 0;
1694        } elseif ( $a > $b ) {
1695            return 1;
1696        } else {
1697            return -1;
1698        }
1699    }
1700
1701    public function split( $newSubject, $reason = '', $newSortkey = null ) {
1702        $oldTopThread = $this->topmostThread();
1703        $oldParent = $this->superthread();
1704
1705        $original = $this->dbVersion;
1706
1707        self::recursiveSet( $this, $newSubject, $this, null );
1708
1709        $oldParent->removeReply( $this );
1710
1711        $bump = null;
1712        if ( $newSortkey !== null ) {
1713            $this->setSortKey( $newSortkey );
1714            $bump = false;
1715        }
1716
1717        // For logging purposes, will be reset by the time this call returns.
1718        $this->dbVersion = $original;
1719        $user = RequestContext::getMain()->getUser(); // Need to inject
1720
1721        $this->commitRevision( Threads::CHANGE_SPLIT, $user, null, $reason, $bump );
1722        $oldTopThread->commitRevision( Threads::CHANGE_SPLIT_FROM, $user, $this, $reason );
1723    }
1724
1725    public function moveToParent( self $newParent, $reason = '' ) {
1726        $newSubject = $newParent->subject();
1727
1728        $original = $this->dbVersion;
1729
1730        $oldTopThread = $newParent->topmostThread();
1731        $oldParent = $this->superthread();
1732        $newTopThread = $newParent->topmostThread();
1733
1734        self::recursiveSet( $this, $newSubject, $newTopThread, $newParent );
1735
1736        $newParent->addReply( $this );
1737
1738        if ( $oldParent ) {
1739            $oldParent->removeReply( $this );
1740        }
1741
1742        $this->dbVersion = $original;
1743        $user = RequestContext::getMain()->getUser(); // Need to inject
1744
1745        $oldTopThread->commitRevision( Threads::CHANGE_MERGED_FROM, $user, $this, $reason );
1746        $newParent->commitRevision( Threads::CHANGE_MERGED_TO, $user, $this, $reason );
1747    }
1748
1749    /**
1750     * @param self $thread
1751     * @param string $subject
1752     * @param self $ancestor
1753     * @param self|null $superthread
1754     */
1755    public static function recursiveSet(
1756        self $thread,
1757        $subject,
1758        self $ancestor,
1759        $superthread = null
1760    ) {
1761        $thread->setSubject( $subject );
1762        $thread->setAncestor( $ancestor->id() );
1763
1764        if ( $superthread ) {
1765            $thread->setSuperThread( $superthread );
1766        }
1767
1768        $thread->save();
1769
1770        foreach ( $thread->replies() as $subThread ) {
1771            self::recursiveSet( $subThread, $subject, $ancestor );
1772        }
1773    }
1774
1775    public static function validateSubject( $subject, User $user, &$title, $replyTo, $article ) {
1776        $t = null;
1777        $ok = true;
1778
1779        while ( !$t ) {
1780            try {
1781                if ( !$replyTo && $subject ) {
1782                    $t = Threads::newThreadTitle( $subject, $article );
1783                } elseif ( $replyTo ) {
1784                    $t = Threads::newReplyTitle( $replyTo, $user );
1785                }
1786
1787                if ( $t ) {
1788                    break;
1789                }
1790            } catch ( Exception $e ) {
1791            }
1792
1793            $subject = md5( (string)mt_rand() ); // Just a random title
1794            $ok = false;
1795        }
1796
1797        $title = $t;
1798
1799        return $ok;
1800    }
1801
1802    /**
1803     * Returns true, or a string with either thread or talkpage, noting which is protected
1804     *
1805     * @param User $user
1806     * @param string $rigor
1807     * @return bool|string
1808     */
1809    public function canUserReply( User $user, $rigor = PermissionManager::RIGOR_SECURE ) {
1810        $rootTitle = $this->topmostThread()->title();
1811        $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore();
1812        $threadRestrictions = $rootTitle ? $restrictionStore->getRestrictions( $rootTitle, 'reply' ) : [];
1813        $talkpageRestrictions = $restrictionStore->getRestrictions( $this->getTitle(), 'reply' );
1814
1815        $threadRestrictions = array_fill_keys( $threadRestrictions, 'thread' );
1816        $talkpageRestrictions = array_fill_keys( $talkpageRestrictions, 'talkpage' );
1817
1818        $restrictions = array_merge( $threadRestrictions, $talkpageRestrictions );
1819
1820        foreach ( $restrictions as $right => $source ) {
1821            if ( $right == 'sysop' ) {
1822                $right = 'protect';
1823            }
1824            if ( !$user->isAllowed( $right ) ) {
1825                return $source;
1826            }
1827        }
1828
1829        return self::canUserCreateThreads( $user, $rigor );
1830    }
1831
1832    /**
1833     * @param User $user
1834     * @param Article $talkpage
1835     * @param string $rigor
1836     * @return bool
1837     */
1838    public static function canUserPost( $user, $talkpage, $rigor = PermissionManager::RIGOR_SECURE ) {
1839        $restrictions = MediaWikiServices::getInstance()->getRestrictionStore()
1840            ->getRestrictions( $talkpage->getTitle(), 'newthread' );
1841
1842        foreach ( $restrictions as $right ) {
1843            if ( !$user->isAllowed( $right ) ) {
1844                return false;
1845            }
1846        }
1847
1848        return self::canUserCreateThreads( $user, $rigor );
1849    }
1850
1851    /**
1852     * Generally, not some specific page
1853     *
1854     * @param User $user
1855     * @param string $rigor
1856     * @return bool
1857     */
1858    public static function canUserCreateThreads( $user, $rigor = PermissionManager::RIGOR_SECURE ) {
1859        $userText = $user->getName();
1860        $pm = MediaWikiServices::getInstance()->getPermissionManager();
1861
1862        static $canCreateNew = [];
1863        if ( !isset( $canCreateNew[$userText] ) ) {
1864            $title = Title::makeTitleSafe(
1865                NS_LQT_THREAD, 'Test title for LQT thread creation check' );
1866            $canCreateNew[$userText] = $pm->userCan( 'edit', $user, $title, $rigor );
1867        }
1868
1869        return $canCreateNew[$userText];
1870    }
1871
1872    /**
1873     * @return string|null Signature wikitext, may be null for very old unserialized comments (T365495)
1874     */
1875    public function signature() {
1876        return $this->signature;
1877    }
1878
1879    public function setSignature( $sig ) {
1880        $sig = LqtView::signaturePST( $sig, $this->author() );
1881        $this->signature = $sig;
1882    }
1883
1884    public function editors() {
1885        if ( $this->editors === null ) {
1886            if ( $this->editedness() < Threads::EDITED_BY_AUTHOR ) {
1887                return [];
1888            } elseif ( $this->editedness == Threads::EDITED_BY_AUTHOR ) {
1889                return [ $this->author()->getName() ];
1890            }
1891
1892            // Load editors
1893            $this->editors = [];
1894
1895            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1896            $revQuery = self::getRevisionQueryInfo();
1897            $res = $dbr->newSelectQueryBuilder()
1898                ->select( [ 'rev_user_text' => $revQuery['fields']['rev_user_text'] ] )
1899                ->tables( $revQuery['tables'] )
1900                ->where( [
1901                    'rev_page' => $this->root()->getPage()->getId(),
1902                    $dbr->expr( 'rev_parent_id', '!=', 0 ),
1903                ] )
1904                ->caller( __METHOD__ )
1905                ->joinConds( $revQuery['joins'] )
1906                ->fetchResultSet();
1907
1908            $editors = [];
1909            foreach ( $res as $row ) {
1910                $editors[$row->rev_user_text] = 1;
1911            }
1912
1913            $this->editors = array_keys( $editors );
1914        }
1915
1916        return $this->editors;
1917    }
1918
1919    public function setEditors( $e ) {
1920        $this->editors = $e;
1921    }
1922
1923    public function addEditor( $e ) {
1924        $this->editors[] = $e;
1925        $this->editors = array_unique( $this->editors );
1926    }
1927
1928    /**
1929     * @return Title the Title object for the article this thread is attached to.
1930     */
1931    public function getTitle() {
1932        return $this->article()->getTitle();
1933    }
1934
1935    public function getReactions( $requestedType = null ) {
1936        if ( $this->reactions === null ) {
1937            if ( isset( self::$reactionCacheById[$this->id()] ) ) {
1938                $this->reactions = self::$reactionCacheById[$this->id()];
1939            } else {
1940                $reactions = [];
1941
1942                $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1943
1944                $res = $dbr->newSelectQueryBuilder()
1945                    ->select( [ 'tr_user', 'tr_user_text', 'tr_type', 'tr_value' ] )
1946                    ->from( 'thread_reaction' )
1947                    ->where( [ 'tr_thread' => $this->id() ] )
1948                    ->caller( __METHOD__ )
1949                    ->fetchResultSet();
1950
1951                foreach ( $res as $row ) {
1952                    $user = $row->tr_user_text;
1953                    $type = $row->tr_type;
1954                    $info = [
1955                        'type' => $type,
1956                        'user-id' => $row->tr_user,
1957                        'user-name' => $row->tr_user_text,
1958                        'value' => $row->tr_value,
1959                    ];
1960
1961                    if ( !isset( $reactions[$type] ) ) {
1962                        $reactions[$type] = [];
1963                    }
1964
1965                    $reactions[$type][$user] = $info;
1966                }
1967
1968                $this->reactions = $reactions;
1969            }
1970        }
1971
1972        if ( $requestedType === null ) {
1973            return $this->reactions;
1974        } else {
1975            return $this->reactions[$requestedType];
1976        }
1977    }
1978
1979    public function addReaction( $user, $type, $value ) {
1980        $info = [
1981            'type' => $type,
1982            'user-id' => $user->getId(),
1983            'user-name' => $user->getName(),
1984            'value' => $value,
1985        ];
1986
1987        if ( !isset( $this->reactions[$type] ) ) {
1988            $this->reactions[$type] = [];
1989        }
1990
1991        $this->reactions[$type][$user->getName()] = $info;
1992
1993        $row = [
1994            'tr_type' => $type,
1995            'tr_thread' => $this->id(),
1996            'tr_user' => $user->getId(),
1997            'tr_user_text' => $user->getName(),
1998            'tr_value' => $value,
1999        ];
2000
2001        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
2002
2003        $dbw->newInsertQueryBuilder()
2004            ->insertInto( 'thread_reaction' )
2005            ->row( $row )
2006            ->caller( __METHOD__ )
2007            ->execute();
2008    }
2009
2010    public function deleteReaction( $user, $type ) {
2011        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
2012
2013        if ( isset( $this->reactions[$type][$user->getName()] ) ) {
2014            unset( $this->reactions[$type][$user->getName()] );
2015        }
2016
2017        $dbw->newDeleteQueryBuilder()
2018            ->deleteFrom( 'thread_reaction' )
2019            ->where( [
2020                'tr_thread' => $this->id(),
2021                'tr_user' => $user->getId(),
2022                'tr_type' => $type
2023            ] )
2024            ->caller( __METHOD__ )
2025            ->execute();
2026    }
2027
2028    /**
2029     * @return array[]
2030     */
2031    private static function getRevisionQueryInfo() {
2032        $info = MediaWikiServices::getInstance()->getRevisionStore()->getQueryInfo();
2033        if ( !isset( $info['fields']['rev_user_text'] ) ) {
2034            $info['fields']['rev_user_text'] = 'rev_user_text';
2035        }
2036
2037        return $info;
2038    }
2039}