Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 422
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeTagsStore
0.00% covered (danger)
0.00%
0 / 422
0.00% covered (danger)
0.00%
0 / 19
7310
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getSoftwareTags
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getTagsWithData
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 makeTagSummarySubquery
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 defineTag
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 undefineTag
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 logTagManagementAction
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 deleteTagEverywhere
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 purgeTagCacheAll
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 tagUsageStatistics
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 listExplicitlyDefinedTags
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 listSoftwareDefinedTags
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 getTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listDefinedTags
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 updateTags
0.00% covered (danger)
0.00%
0 / 133
0.00% covered (danger)
0.00%
0 / 1
462
 addTags
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 listSoftwareActivatedTags
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 modifyDisplayQuery
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
306
 modifyDisplayQueryBuilder
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
272
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Change tagging
20 */
21
22namespace MediaWiki\ChangeTags;
23
24use InvalidArgumentException;
25use ManualLogEntry;
26use MediaWiki\Config\ServiceOptions;
27use MediaWiki\HookContainer\HookContainer;
28use MediaWiki\HookContainer\HookRunner;
29use MediaWiki\MainConfigNames;
30use MediaWiki\Status\Status;
31use MediaWiki\Storage\NameTableAccessException;
32use MediaWiki\Storage\NameTableStore;
33use MediaWiki\Title\Title;
34use MediaWiki\User\UserFactory;
35use MediaWiki\User\UserIdentity;
36use Psr\Log\LoggerInterface;
37use RecentChange;
38use WANObjectCache;
39use Wikimedia\Rdbms\Database;
40use Wikimedia\Rdbms\IConnectionProvider;
41use Wikimedia\Rdbms\IReadableDatabase;
42use Wikimedia\Rdbms\SelectQueryBuilder;
43
44/**
45 * Gateway class for change_tags table
46 *
47 * @since 1.41
48 */
49class ChangeTagsStore {
50
51    /**
52     * Name of change_tag table
53     */
54    private const CHANGE_TAG = 'change_tag';
55
56    /**
57     * Name of change_tag_def table
58     */
59    private const CHANGE_TAG_DEF = 'change_tag_def';
60
61    public const DISPLAY_TABLE_ALIAS = 'changetagdisplay';
62
63    /**
64     * @internal For use by ServiceWiring
65     */
66    public const CONSTRUCTOR_OPTIONS = [
67        MainConfigNames::SoftwareTags,
68        MainConfigNames::UseTagFilter,
69    ];
70
71    /**
72     * A list of tags defined and used by MediaWiki itself.
73     */
74    private const DEFINED_SOFTWARE_TAGS = [
75        'mw-contentmodelchange',
76        'mw-new-redirect',
77        'mw-removed-redirect',
78        'mw-changed-redirect-target',
79        'mw-blank',
80        'mw-replace',
81        'mw-rollback',
82        'mw-undo',
83        'mw-manual-revert',
84        'mw-reverted',
85        'mw-server-side-upload',
86    ];
87
88    private IConnectionProvider $dbProvider;
89    private LoggerInterface $logger;
90    private ServiceOptions $options;
91    private NameTableStore $changeTagDefStore;
92    private WANObjectCache $wanCache;
93    private HookRunner $hookRunner;
94    private UserFactory $userFactory;
95    private HookContainer $hookContainer;
96
97    public function __construct(
98        IConnectionProvider $dbProvider,
99        NameTableStore $changeTagDefStore,
100        WANObjectCache $wanCache,
101        HookContainer $hookContainer,
102        LoggerInterface $logger,
103        UserFactory $userFactory,
104        ServiceOptions $options
105    ) {
106        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
107        $this->dbProvider = $dbProvider;
108        $this->logger = $logger;
109        $this->options = $options;
110        $this->changeTagDefStore = $changeTagDefStore;
111        $this->wanCache = $wanCache;
112        $this->hookContainer = $hookContainer;
113        $this->userFactory = $userFactory;
114        $this->hookRunner = new HookRunner( $hookContainer );
115    }
116
117    /**
118     * Loads defined core tags, checks for invalid types (if not array),
119     * and filters for supported and enabled (if $all is false) tags only.
120     *
121     * @param bool $all If true, return all valid defined tags. Otherwise, return only enabled ones.
122     * @return array Array of all defined/enabled tags.
123     */
124    public function getSoftwareTags( $all = false ): array {
125        $coreTags = $this->options->get( MainConfigNames::SoftwareTags );
126        if ( !is_array( $coreTags ) ) {
127            $this->logger->warning( 'wgSoftwareTags should be associative array of enabled tags.
128            Please refer to documentation for the list of tags you can enable' );
129            return [];
130        }
131
132        $availableSoftwareTags = !$all ?
133            array_keys( array_filter( $coreTags ) ) :
134            array_keys( $coreTags );
135
136        return array_intersect(
137            $availableSoftwareTags,
138            self::DEFINED_SOFTWARE_TAGS
139        );
140    }
141
142    /**
143     * Return all the tags associated with the given recent change ID,
144     * revision ID, and/or log entry ID, along with any data stored with the tag.
145     *
146     * @param IReadableDatabase $db the database to query
147     * @param int|null $rc_id
148     * @param int|null $rev_id
149     * @param int|null $log_id
150     * @return string[] Tag name => data. Data format is tag-specific.
151     * @since 1.41
152     */
153    public function getTagsWithData(
154        IReadableDatabase $db, $rc_id = null, $rev_id = null, $log_id = null
155    ): array {
156        if ( !$rc_id && !$rev_id && !$log_id ) {
157            throw new InvalidArgumentException(
158                'At least one of: RCID, revision ID, and log ID MUST be ' .
159                'specified when loading tags from a change!' );
160        }
161
162        $conds = array_filter(
163            [
164                'ct_rc_id' => $rc_id,
165                'ct_rev_id' => $rev_id,
166                'ct_log_id' => $log_id,
167            ]
168        );
169        $result = $db->newSelectQueryBuilder()
170            ->select( [ 'ct_tag_id', 'ct_params' ] )
171            ->from( self::CHANGE_TAG )
172            ->where( $conds )
173            ->caller( __METHOD__ )
174            ->fetchResultSet();
175
176        $tags = [];
177        foreach ( $result as $row ) {
178            $tagName = $this->changeTagDefStore->getName( (int)$row->ct_tag_id );
179            $tags[$tagName] = $row->ct_params;
180        }
181
182        return $tags;
183    }
184
185    /**
186     * Make the tag summary subquery based on the given tables and return it.
187     *
188     * @param string|array $tables Table names, see Database::select
189     *
190     * @return string tag summary subqeury
191     */
192    public function makeTagSummarySubquery( $tables ) {
193        // Normalize to arrays
194        $tables = (array)$tables;
195
196        // Figure out which ID field to use
197        if ( in_array( 'recentchanges', $tables ) ) {
198            $join_cond = 'ct_rc_id=rc_id';
199        } elseif ( in_array( 'logging', $tables ) ) {
200            $join_cond = 'ct_log_id=log_id';
201        } elseif ( in_array( 'revision', $tables ) ) {
202            $join_cond = 'ct_rev_id=rev_id';
203        } elseif ( in_array( 'archive', $tables ) ) {
204            $join_cond = 'ct_rev_id=ar_rev_id';
205        } else {
206            throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
207        }
208
209        $tagTables = [ self::CHANGE_TAG, self::CHANGE_TAG_DEF ];
210        $join_cond_ts_tags = [ self::CHANGE_TAG_DEF => [ 'JOIN', 'ct_tag_id=ctd_id' ] ];
211        $field = 'ctd_name';
212
213        return $this->dbProvider->getReplicaDatabase()
214            ->buildGroupConcatField( ',', $tagTables, $field, $join_cond, $join_cond_ts_tags );
215    }
216
217    /**
218     * Set ctd_user_defined = 1 in change_tag_def without checking that the tag name is valid.
219     * Extensions should NOT use this function; they can use the ListDefinedTags
220     * hook instead.
221     *
222     * @param string $tag Tag to create
223     * @since 1.41
224     */
225    public function defineTag( $tag ) {
226        $dbw = $this->dbProvider->getPrimaryDatabase();
227        $dbw->newInsertQueryBuilder()
228            ->insertInto( self::CHANGE_TAG_DEF )
229            ->row( [
230                'ctd_name' => $tag,
231                'ctd_user_defined' => 1,
232                'ctd_count' => 0
233            ] )
234            ->onDuplicateKeyUpdate()
235            ->uniqueIndexFields( [ 'ctd_name' ] )
236            ->set( [ 'ctd_user_defined' => 1 ] )
237            ->caller( __METHOD__ )->execute();
238
239        // clear the memcache of defined tags
240        $this->purgeTagCacheAll();
241    }
242
243    /**
244     * Update ctd_user_defined = 0 field in change_tag_def.
245     * The tag may remain in use by extensions, and may still show up as 'defined'
246     * if an extension is setting it from the ListDefinedTags hook.
247     *
248     * @param string $tag Tag to remove
249     * @since 1.41
250     */
251    public function undefineTag( $tag ) {
252        $dbw = $this->dbProvider->getPrimaryDatabase();
253
254        $dbw->newUpdateQueryBuilder()
255            ->update( self::CHANGE_TAG_DEF )
256            ->set( [ 'ctd_user_defined' => 0 ] )
257            ->where( [ 'ctd_name' => $tag ] )
258            ->caller( __METHOD__ )->execute();
259
260        $dbw->newDeleteQueryBuilder()
261            ->deleteFrom( self::CHANGE_TAG_DEF )
262            ->where( [ 'ctd_name' => $tag, 'ctd_count' => 0 ] )
263            ->caller( __METHOD__ )->execute();
264
265        // clear the memcache of defined tags
266        $this->purgeTagCacheAll();
267    }
268
269    /**
270     * Writes a tag action into the tag management log.
271     *
272     * @param string $action
273     * @param string $tag
274     * @param string $reason
275     * @param UserIdentity $user Who to attribute the action to
276     * @param int|null $tagCount For deletion only, how many usages the tag had before
277     * it was deleted.
278     * @param array $logEntryTags Change tags to apply to the entry
279     * that will be created in the tag management log
280     * @return int ID of the inserted log entry
281     * @since 1.41
282     */
283    public function logTagManagementAction( string $action, string $tag, string $reason,
284        UserIdentity $user, $tagCount = null, array $logEntryTags = []
285    ) {
286        $dbw = $this->dbProvider->getPrimaryDatabase();
287
288        $logEntry = new ManualLogEntry( 'managetags', $action );
289        $logEntry->setPerformer( $user );
290        // target page is not relevant, but it has to be set, so we just put in
291        // the title of Special:Tags
292        $logEntry->setTarget( Title::newFromText( 'Special:Tags' ) );
293        $logEntry->setComment( $reason );
294
295        $params = [ '4::tag' => $tag ];
296        if ( $tagCount !== null ) {
297            $params['5:number:count'] = $tagCount;
298        }
299        $logEntry->setParameters( $params );
300        $logEntry->setRelations( [ 'Tag' => $tag ] );
301        $logEntry->addTags( $logEntryTags );
302
303        $logId = $logEntry->insert( $dbw );
304        $logEntry->publish( $logId );
305        return $logId;
306    }
307
308    /**
309     * Permanently removes all traces of a tag from the DB. Good for removing
310     * misspelt or temporary tags.
311     *
312     * This function should be directly called by maintenance scripts only, never
313     * by user-facing code. See deleteTagWithChecks() for functionality that can
314     * safely be exposed to users.
315     *
316     * @param string $tag Tag to remove
317     * @return Status The returned status will be good unless a hook changed it
318     * @since 1.41
319     */
320    public function deleteTagEverywhere( $tag ) {
321        $dbw = $this->dbProvider->getPrimaryDatabase();
322        $dbw->startAtomic( __METHOD__ );
323
324        // fetch tag id, this must be done before calling undefineTag(), see T225564
325        $tagId = $this->changeTagDefStore->getId( $tag );
326
327        // set ctd_user_defined = 0
328        $this->undefineTag( $tag );
329
330        // delete from change_tag
331        $dbw->newDeleteQueryBuilder()
332            ->deleteFrom( self::CHANGE_TAG )
333            ->where( [ 'ct_tag_id' => $tagId ] )
334            ->caller( __METHOD__ )->execute();
335        $dbw->newDeleteQueryBuilder()
336            ->deleteFrom( self::CHANGE_TAG_DEF )
337            ->where( [ 'ctd_name' => $tag ] )
338            ->caller( __METHOD__ )->execute();
339        $dbw->endAtomic( __METHOD__ );
340
341        // give extensions a chance
342        $status = Status::newGood();
343        $this->hookRunner->onChangeTagAfterDelete( $tag, $status );
344        // let's not allow error results, as the actual tag deletion succeeded
345        if ( !$status->isOK() ) {
346            $this->logger->debug( 'ChangeTagAfterDelete error condition downgraded to warning' );
347            $status->setOK( true );
348        }
349
350        // clear the memcache of defined tags
351        $this->purgeTagCacheAll();
352
353        return $status;
354    }
355
356    /**
357     * Invalidates the short-term cache of defined tags used by the
358     * list*DefinedTags functions, as well as the tag statistics cache.
359     * @since 1.41
360     */
361    public function purgeTagCacheAll() {
362        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'active-tags' ) );
363        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'valid-tags-db' ) );
364        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'valid-tags-hook' ) );
365        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'tags-usage-statistics' ) );
366
367        $this->changeTagDefStore->reloadMap();
368    }
369
370    /**
371     * Returns a map of any tags used on the wiki to number of edits
372     * tagged with them, ordered descending by the hitcount.
373     * This does not include tags defined somewhere that have never been applied.
374     * @return array Array of string => int
375     */
376    public function tagUsageStatistics(): array {
377        $fname = __METHOD__;
378        $dbProvider = $this->dbProvider;
379
380        return $this->wanCache->getWithSetCallback(
381            $this->wanCache->makeKey( 'tags-usage-statistics' ),
382            WANObjectCache::TTL_MINUTE * 5,
383            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbProvider ) {
384                $dbr = $dbProvider->getReplicaDatabase();
385                $res = $dbr->newSelectQueryBuilder()
386                    ->select( [ 'ctd_name', 'ctd_count' ] )
387                    ->from( self::CHANGE_TAG_DEF )
388                    ->orderBy( 'ctd_count', SelectQueryBuilder::SORT_DESC )
389                    ->caller( $fname )
390                    ->fetchResultSet();
391
392                $out = [];
393                foreach ( $res as $row ) {
394                    $out[$row->ctd_name] = $row->ctd_count;
395                }
396
397                return $out;
398            },
399            [
400                'checkKeys' => [ $this->wanCache->makeKey( 'tags-usage-statistics' ) ],
401                'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
402                'pcTTL' => WANObjectCache::TTL_PROC_LONG
403            ]
404        );
405    }
406
407    /**
408     * Lists tags explicitly defined in the `change_tag_def` table of the database.
409     *
410     * Tries memcached first.
411     *
412     * @return string[] Array of strings: tags
413     * @since 1.25
414     */
415    public function listExplicitlyDefinedTags() {
416        $fname = __METHOD__;
417        $dbProvider = $this->dbProvider;
418
419        return $this->wanCache->getWithSetCallback(
420            $this->wanCache->makeKey( 'valid-tags-db' ),
421            WANObjectCache::TTL_MINUTE * 5,
422            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbProvider ) {
423                $dbr = $dbProvider->getReplicaDatabase();
424                $setOpts += Database::getCacheSetOptions( $dbr );
425                $tags = $dbr->newSelectQueryBuilder()
426                    ->select( 'ctd_name' )
427                    ->from( self::CHANGE_TAG_DEF )
428                    ->where( [ 'ctd_user_defined' => 1 ] )
429                    ->caller( $fname )
430                    ->fetchFieldValues();
431
432                return array_unique( $tags );
433            },
434            [
435                'checkKeys' => [ $this->wanCache->makeKey( 'valid-tags-db' ) ],
436                'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
437                'pcTTL' => WANObjectCache::TTL_PROC_LONG
438            ]
439        );
440    }
441
442    /**
443     * Lists tags defined by core or extensions using the ListDefinedTags hook.
444     * Extensions need only define those tags they deem to be in active use.
445     *
446     * Tries memcached first.
447     *
448     * @return string[] Array of strings: tags
449     * @since 1.25
450     */
451    public function listSoftwareDefinedTags() {
452        // core defined tags
453        $tags = $this->getSoftwareTags( true );
454        if ( !$this->hookContainer->isRegistered( 'ListDefinedTags' ) ) {
455            return $tags;
456        }
457        $hookRunner = $this->hookRunner;
458        $dbProvider = $this->dbProvider;
459        return $this->wanCache->getWithSetCallback(
460            $this->wanCache->makeKey( 'valid-tags-hook' ),
461            WANObjectCache::TTL_MINUTE * 5,
462            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner, $dbProvider ) {
463                $setOpts += Database::getCacheSetOptions( $dbProvider->getReplicaDatabase() );
464                $hookRunner->onListDefinedTags( $tags );
465                return array_unique( $tags );
466            },
467            [
468                'checkKeys' => [ $this->wanCache->makeKey( 'valid-tags-hook' ) ],
469                'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
470                'pcTTL' => WANObjectCache::TTL_PROC_LONG
471            ]
472        );
473    }
474
475    /**
476     * Return all the tags associated with the given recent change ID,
477     * revision ID, and/or log entry ID.
478     *
479     * @param IReadableDatabase $db the database to query
480     * @param int|null $rc_id
481     * @param int|null $rev_id
482     * @param int|null $log_id
483     * @return string[]
484     */
485    public function getTags( IReadableDatabase $db, $rc_id = null, $rev_id = null, $log_id = null ) {
486        return array_keys( $this->getTagsWithData( $db, $rc_id, $rev_id, $log_id ) );
487    }
488
489    /**
490     * Basically lists defined tags which count even if they aren't applied to anything.
491     * It returns a union of the results of listExplicitlyDefinedTags() and
492     * listSoftwareDefinedTags()
493     *
494     * @return string[] Array of strings: tags
495     */
496    public function listDefinedTags() {
497        $tags1 = $this->listExplicitlyDefinedTags();
498        $tags2 = $this->listSoftwareDefinedTags();
499        return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
500    }
501
502    /**
503     * Add and remove tags to/from a change given its rc_id, rev_id and/or log_id,
504     * without verifying that the tags exist or are valid. If a tag is present in
505     * both $tagsToAdd and $tagsToRemove, it will be removed.
506     *
507     * This function should only be used by extensions to manipulate tags they
508     * have registered using the ListDefinedTags hook. When dealing with user
509     * input, call updateTagsWithChecks() instead.
510     *
511     * @param string|array|null $tagsToAdd Tags to add to the change
512     * @param string|array|null $tagsToRemove Tags to remove from the change
513     * @param int|null &$rc_id The rc_id of the change to add the tags to.
514     * Pass a variable whose value is null if the rc_id is not relevant or unknown.
515     * @param int|null &$rev_id The rev_id of the change to add the tags to.
516     * Pass a variable whose value is null if the rev_id is not relevant or unknown.
517     * @param int|null &$log_id The log_id of the change to add the tags to.
518     * Pass a variable whose value is null if the log_id is not relevant or unknown.
519     * @param string|null $params Params to put in the ct_params field of table
520     * 'change_tag' when adding tags
521     * @param RecentChange|null $rc Recent change being tagged, in case the tagging accompanies
522     * the action
523     * @param UserIdentity|null $user Tagging user, in case the tagging is subsequent to the tagged action
524     *
525     * @return array Index 0 is an array of tags actually added, index 1 is an
526     * array of tags actually removed, index 2 is an array of tags present on the
527     * revision or log entry before any changes were made
528     */
529    public function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
530        &$rev_id = null, &$log_id = null, $params = null, RecentChange $rc = null,
531        UserIdentity $user = null
532    ) {
533        $tagsToAdd = array_filter(
534            (array)$tagsToAdd, // Make sure we're submitting all tags...
535            static function ( $value ) {
536                return ( $value ?? '' ) !== '';
537            }
538        );
539        $tagsToRemove = array_filter(
540            (array)$tagsToRemove,
541            static function ( $value ) {
542                return ( $value ?? '' ) !== '';
543            }
544        );
545
546        if ( !$rc_id && !$rev_id && !$log_id ) {
547            throw new InvalidArgumentException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
548                'specified when adding or removing a tag from a change!' );
549        }
550
551        $dbw = $this->dbProvider->getPrimaryDatabase();
552
553        // Might as well look for rcids and so on.
554        if ( !$rc_id ) {
555            // Info might be out of date, somewhat fractionally, on replica DB.
556            // LogEntry/LogPage and WikiPage match rev/log/rc timestamps,
557            // so use that relation to avoid full table scans.
558            if ( $log_id ) {
559                $rc_id = $dbw->newSelectQueryBuilder()
560                    ->select( 'rc_id' )
561                    ->from( 'logging' )
562                    ->join( 'recentchanges', null, [
563                        'rc_timestamp = log_timestamp',
564                        'rc_logid = log_id'
565                    ] )
566                    ->where( [ 'log_id' => $log_id ] )
567                    ->caller( __METHOD__ )
568                    ->fetchField();
569            } elseif ( $rev_id ) {
570                $rc_id = $dbw->newSelectQueryBuilder()
571                    ->select( 'rc_id' )
572                    ->from( 'revision' )
573                    ->join( 'recentchanges', null, [
574                        'rc_this_oldid = rev_id'
575                    ] )
576                    ->where( [ 'rev_id' => $rev_id ] )
577                    ->caller( __METHOD__ )
578                    ->fetchField();
579            }
580        } elseif ( !$log_id && !$rev_id ) {
581            // Info might be out of date, somewhat fractionally, on replica DB.
582            $log_id = $dbw->newSelectQueryBuilder()
583                ->select( 'rc_logid' )
584                ->from( 'recentchanges' )
585                ->where( [ 'rc_id' => $rc_id ] )
586                ->caller( __METHOD__ )
587                ->fetchField();
588            $rev_id = $dbw->newSelectQueryBuilder()
589                ->select( 'rc_this_oldid' )
590                ->from( 'recentchanges' )
591                ->where( [ 'rc_id' => $rc_id ] )
592                ->caller( __METHOD__ )
593                ->fetchField();
594        }
595
596        if ( $log_id && !$rev_id ) {
597            $rev_id = $dbw->newSelectQueryBuilder()
598                ->select( 'ls_value' )
599                ->from( 'log_search' )
600                ->where( [ 'ls_field' => 'associated_rev_id', 'ls_log_id' => $log_id ] )
601                ->caller( __METHOD__ )
602                ->fetchField();
603        } elseif ( !$log_id && $rev_id ) {
604            $log_id = $dbw->newSelectQueryBuilder()
605                ->select( 'ls_log_id' )
606                ->from( 'log_search' )
607                ->where( [ 'ls_field' => 'associated_rev_id', 'ls_value' => (string)$rev_id ] )
608                ->caller( __METHOD__ )
609                ->fetchField();
610        }
611
612        $prevTags = $this->getTags( $dbw, $rc_id, $rev_id, $log_id );
613
614        // add tags
615        $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
616        $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
617
618        // remove tags
619        $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
620        $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
621
622        sort( $prevTags );
623        sort( $newTags );
624        if ( $prevTags == $newTags ) {
625            return [ [], [], $prevTags ];
626        }
627
628        // insert a row into change_tag for each new tag
629        if ( count( $tagsToAdd ) ) {
630            $changeTagMapping = [];
631            foreach ( $tagsToAdd as $tag ) {
632                $changeTagMapping[$tag] = $this->changeTagDefStore->acquireId( $tag );
633            }
634            $fname = __METHOD__;
635            // T207881: update the counts at the end of the transaction
636            $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tagsToAdd, $fname ) {
637                $dbw->newUpdateQueryBuilder()
638                    ->update( self::CHANGE_TAG_DEF )
639                    ->set( [ 'ctd_count = ctd_count + 1' ] )
640                    ->where( [ 'ctd_name' => $tagsToAdd ] )
641                    ->caller( $fname )->execute();
642            }, $fname );
643
644            $tagsRows = [];
645            foreach ( $tagsToAdd as $tag ) {
646                // Filter so we don't insert NULLs as zero accidentally.
647                // Keep in mind that $rc_id === null means "I don't care/know about the
648                // rc_id, just delete $tag on this revision/log entry". It doesn't
649                // mean "only delete tags on this revision/log WHERE rc_id IS NULL".
650                $tagsRows[] = array_filter(
651                    [
652                        'ct_rc_id' => $rc_id,
653                        'ct_log_id' => $log_id,
654                        'ct_rev_id' => $rev_id,
655                        'ct_params' => $params,
656                        'ct_tag_id' => $changeTagMapping[$tag] ?? null,
657                    ]
658                );
659
660            }
661
662            $dbw->newInsertQueryBuilder()
663                ->insertInto( self::CHANGE_TAG )
664                ->ignore()
665                ->rows( $tagsRows )
666                ->caller( __METHOD__ )->execute();
667        }
668
669        // delete from change_tag
670        if ( count( $tagsToRemove ) ) {
671            $fname = __METHOD__;
672            foreach ( $tagsToRemove as $tag ) {
673                $conds = array_filter(
674                    [
675                        'ct_rc_id' => $rc_id,
676                        'ct_log_id' => $log_id,
677                        'ct_rev_id' => $rev_id,
678                        'ct_tag_id' => $this->changeTagDefStore->getId( $tag ),
679                    ]
680                );
681                $dbw->newDeleteQueryBuilder()
682                    ->deleteFrom( self::CHANGE_TAG )
683                    ->where( $conds )
684                    ->caller( __METHOD__ )->execute();
685                if ( $dbw->affectedRows() ) {
686                    // T207881: update the counts at the end of the transaction
687                    $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tag, $fname ) {
688                        $dbw->newUpdateQueryBuilder()
689                            ->update( self::CHANGE_TAG_DEF )
690                            ->set( [ 'ctd_count = ctd_count - 1' ] )
691                            ->where( [ 'ctd_name' => $tag ] )
692                            ->caller( $fname )->execute();
693
694                        $dbw->newDeleteQueryBuilder()
695                            ->deleteFrom( self::CHANGE_TAG_DEF )
696                            ->where( [ 'ctd_name' => $tag, 'ctd_count' => 0, 'ctd_user_defined' => 0 ] )
697                            ->caller( $fname )->execute();
698                    }, $fname );
699                }
700            }
701        }
702
703        $userObj = $user ? $this->userFactory->newFromUserIdentity( $user ) : null;
704        $this->hookRunner->onChangeTagsAfterUpdateTags(
705            $tagsToAdd, $tagsToRemove, $prevTags, $rc_id, $rev_id, $log_id, $params, $rc, $userObj );
706
707        return [ $tagsToAdd, $tagsToRemove, $prevTags ];
708    }
709
710    /**
711     * Add tags to a change given its rc_id, rev_id and/or log_id
712     *
713     * @param string|string[] $tags Tags to add to the change
714     * @param int|null $rc_id The rc_id of the change to add the tags to
715     * @param int|null $rev_id The rev_id of the change to add the tags to
716     * @param int|null $log_id The log_id of the change to add the tags to
717     * @param string|null $params Params to put in the ct_params field of table 'change_tag'
718     * @param RecentChange|null $rc Recent change, in case the tagging accompanies the action
719     * (this should normally be the case)
720     *
721     * @return bool False if no changes are made, otherwise true
722     */
723    public function addTags( $tags, $rc_id = null, $rev_id = null,
724        $log_id = null, $params = null, RecentChange $rc = null
725    ) {
726        $result = $this->updateTags( $tags, null, $rc_id, $rev_id, $log_id, $params, $rc );
727        return (bool)$result[0];
728    }
729
730    /**
731     * Lists those tags which core or extensions report as being "active".
732     *
733     * @return array
734     * @since 1.41
735     */
736    public function listSoftwareActivatedTags() {
737        // core active tags
738        $tags = $this->getSoftwareTags();
739        if ( !$this->hookContainer->isRegistered( 'ChangeTagsListActive' ) ) {
740            return $tags;
741        }
742        $hookRunner = $this->hookRunner;
743        $dbProvider = $this->dbProvider;
744
745        return $this->wanCache->getWithSetCallback(
746            $this->wanCache->makeKey( 'active-tags' ),
747            WANObjectCache::TTL_MINUTE * 5,
748            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner, $dbProvider ) {
749                $setOpts += Database::getCacheSetOptions( $dbProvider->getReplicaDatabase() );
750
751                // Ask extensions which tags they consider active
752                $hookRunner->onChangeTagsListActive( $tags );
753                return $tags;
754            },
755            [
756                'checkKeys' => [ $this->wanCache->makeKey( 'active-tags' ) ],
757                'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
758                'pcTTL' => WANObjectCache::TTL_PROC_LONG
759            ]
760        );
761    }
762
763    /**
764     * Applies all tags-related changes to a query.
765     * Handles selecting tags, and filtering.
766     * Needs $tables to be set up properly, so we can figure out which join conditions to use.
767     *
768     * WARNING: If $filter_tag contains more than one tag and $exclude is false, this function
769     * will add DISTINCT, which may cause performance problems for your query unless you put
770     * the ID field of your table at the end of the ORDER BY, and set a GROUP BY equal to the
771     * ORDER BY. For example, if you had ORDER BY foo_timestamp DESC, you will now need
772     * GROUP BY foo_timestamp, foo_id ORDER BY foo_timestamp DESC, foo_id DESC.
773     *
774     * @deprecated since 1.41 use ChangeTagsStore::modifyDisplayQueryBuilder instead
775     *
776     * @param string|array &$tables Table names, see Database::select
777     * @param string|array &$fields Fields used in query, see Database::select
778     * @param string|array &$conds Conditions used in query, see Database::select
779     * @param array &$join_conds Join conditions, see Database::select
780     * @param string|array &$options Options, see Database::select
781     * @param string|array|false|null $filter_tag Tag(s) to select on (OR)
782     * @param bool $exclude If true, exclude tag(s) from $filter_tag (NOR)
783     *
784     */
785    public function modifyDisplayQuery( &$tables, &$fields, &$conds,
786        &$join_conds, &$options, $filter_tag = '', bool $exclude = false
787    ) {
788        $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
789
790        // Normalize to arrays
791        $tables = (array)$tables;
792        $fields = (array)$fields;
793        $conds = (array)$conds;
794        $options = (array)$options;
795
796        $fields['ts_tags'] = $this->makeTagSummarySubquery( $tables );
797        // We use an alias and qualify the conditions in case there are
798        // multiple joins to this table.
799        // In particular for compatibility with the RC filters that extension Translate does.
800
801        // Figure out which ID field to use
802        if ( in_array( 'recentchanges', $tables ) ) {
803            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rc_id=rc_id';
804        } elseif ( in_array( 'logging', $tables ) ) {
805            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_log_id=log_id';
806        } elseif ( in_array( 'revision', $tables ) ) {
807            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=rev_id';
808        } elseif ( in_array( 'archive', $tables ) ) {
809            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=ar_rev_id';
810        } else {
811            throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
812        }
813
814        if ( !$useTagFilter ) {
815            return;
816        }
817
818        if ( !is_array( $filter_tag ) ) {
819            // some callers provide false or null
820            $filter_tag = (string)$filter_tag;
821        }
822
823        if ( $filter_tag !== [] && $filter_tag !== '' ) {
824            // Somebody wants to filter on a tag.
825            // Add an INNER JOIN on change_tag
826            $filterTagIds = [];
827            foreach ( (array)$filter_tag as $filterTagName ) {
828                try {
829                    $filterTagIds[] = $this->changeTagDefStore->getId( $filterTagName );
830                } catch ( NameTableAccessException $exception ) {
831                }
832            }
833
834            if ( $exclude ) {
835                if ( $filterTagIds !== [] ) {
836                    $tables[self::DISPLAY_TABLE_ALIAS] = self::CHANGE_TAG;
837                    $join_conds[self::DISPLAY_TABLE_ALIAS] = [
838                        'LEFT JOIN',
839                        [ $join_cond, self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => $filterTagIds ]
840                    ];
841                    $conds[self::DISPLAY_TABLE_ALIAS . '.ct_tag_id'] = null;
842                }
843            } else {
844                $tables[self::DISPLAY_TABLE_ALIAS] = self::CHANGE_TAG;
845                $join_conds[self::DISPLAY_TABLE_ALIAS] = [ 'JOIN', $join_cond ];
846                if ( $filterTagIds !== [] ) {
847                    $conds[self::DISPLAY_TABLE_ALIAS . '.ct_tag_id'] = $filterTagIds;
848                } else {
849                    // all tags were invalid, return nothing
850                    $conds[] = '0=1';
851                }
852
853                if (
854                    is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
855                    !in_array( 'DISTINCT', $options )
856                ) {
857                    $options[] = 'DISTINCT';
858                }
859            }
860        }
861    }
862
863    /**
864     * Applies all tags-related changes to a query builder object.
865     *
866     * Handles selecting tags, and filtering.
867     *
868     * WARNING: If $filter_tag contains more than one tag and $exclude is false, this function
869     * will add DISTINCT, which may cause performance problems for your query unless you put
870     * the ID field of your table at the end of the ORDER BY, and set a GROUP BY equal to the
871     * ORDER BY. For example, if you had ORDER BY foo_timestamp DESC, you will now need
872     * GROUP BY foo_timestamp, foo_id ORDER BY foo_timestamp DESC, foo_id DESC.
873     *
874     * @param SelectQueryBuilder $queryBuilder Query builder to add the join
875     * @param string $table Table name. Must be either of 'recentchanges', 'logging', 'revision', or 'archive'
876     * @param string|array|false|null $filter_tag Tag(s) to select on (OR)
877     * @param bool $exclude If true, exclude tag(s) from $filter_tag (NOR)
878     *
879     */
880    public function modifyDisplayQueryBuilder(
881        SelectQueryBuilder $queryBuilder,
882        $table,
883        $filter_tag = '',
884        bool $exclude = false
885    ) {
886        $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
887        $queryBuilder->field( $this->makeTagSummarySubquery( [ $table ] ), 'ts_tags' );
888
889        // We use an alias and qualify the conditions in case there are
890        // multiple joins to this table.
891        // In particular for compatibility with the RC filters that extension Translate does.
892        // Figure out which ID field to use
893        if ( $table === 'recentchanges' ) {
894            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rc_id=rc_id';
895        } elseif ( $table === 'logging' ) {
896            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_log_id=log_id';
897        } elseif ( $table === 'revision' ) {
898            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=rev_id';
899        } elseif ( $table === 'archive' ) {
900            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=ar_rev_id';
901        } else {
902            throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
903        }
904
905        if ( !$useTagFilter ) {
906            return;
907        }
908
909        if ( !is_array( $filter_tag ) ) {
910            // some callers provide false or null
911            $filter_tag = (string)$filter_tag;
912        }
913
914        if ( $filter_tag !== [] && $filter_tag !== '' ) {
915            // Somebody wants to filter on a tag.
916            // Add an INNER JOIN on change_tag
917            $filterTagIds = [];
918            foreach ( (array)$filter_tag as $filterTagName ) {
919                try {
920                    $filterTagIds[] = $this->changeTagDefStore->getId( $filterTagName );
921                } catch ( NameTableAccessException $exception ) {
922                }
923            }
924
925            if ( $exclude ) {
926                if ( $filterTagIds !== [] ) {
927                    $queryBuilder->leftJoin(
928                        self::CHANGE_TAG,
929                        self::DISPLAY_TABLE_ALIAS,
930                        [ $join_cond, self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => $filterTagIds ]
931                    );
932                    $queryBuilder->where( [ self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => null ] );
933                }
934            } else {
935                $queryBuilder->join(
936                    self::CHANGE_TAG,
937                    self::DISPLAY_TABLE_ALIAS,
938                    $join_cond
939                );
940                if ( $filterTagIds !== [] ) {
941                    $queryBuilder->where( [ self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => $filterTagIds ] );
942                } else {
943                    // all tags were invalid, return nothing
944                    $queryBuilder->where( '0=1' );
945                }
946
947                if (
948                    is_array( $filter_tag ) && count( $filter_tag ) > 1
949                ) {
950                    $queryBuilder->distinct();
951                }
952            }
953        }
954    }
955}