Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.47% covered (warning)
79.47%
387 / 487
60.00% covered (warning)
60.00%
18 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangeTagsStore
79.47% covered (warning)
79.47%
387 / 487
60.00% covered (warning)
60.00%
18 / 30
214.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getSoftwareTags
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
4.37
 getCoreDefinedTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTagsWithData
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
5.06
 makeTagSummarySubquery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildTagSummarySubquery
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 defineTag
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 undefineTag
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 logTagManagementAction
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 deleteTagEverywhere
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 purgeTagCacheAll
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 tagUsageStatistics
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 listExplicitlyDefinedTags
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 listSoftwareDefinedTags
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 getTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getViewableTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listDefinedTags
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getTagIdsFromNames
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 isRestrictedTag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRestrictedTagRights
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
4.41
 canViewTag
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 filterViewableTags
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 filterViewableTagsForPerformer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthorityForPublicTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateTags
97.78% covered (success)
97.78%
132 / 135
0.00% covered (danger)
0.00%
0 / 1
23
 addTags
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 listSoftwareActivatedTags
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 modifyDisplayQuery
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
14
 addTagsToDisplayQuery
63.89% covered (warning)
63.89%
23 / 36
0.00% covered (danger)
0.00%
0 / 1
20.96
 modifyDisplayQueryBuilder
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\ChangeTags;
8
9use InvalidArgumentException;
10use LogicException;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\HookContainer\HookContainer;
13use MediaWiki\HookContainer\HookRunner;
14use MediaWiki\Logging\ManualLogEntry;
15use MediaWiki\MainConfigNames;
16use MediaWiki\Permissions\Authority;
17use MediaWiki\Permissions\SimpleAuthority;
18use MediaWiki\RecentChanges\RecentChange;
19use MediaWiki\Status\Status;
20use MediaWiki\Storage\NameTableAccessException;
21use MediaWiki\Storage\NameTableStore;
22use MediaWiki\Title\Title;
23use MediaWiki\User\UserFactory;
24use MediaWiki\User\UserIdentity;
25use MediaWiki\User\UserIdentityValue;
26use Psr\Log\LoggerInterface;
27use Wikimedia\ObjectCache\WANObjectCache;
28use Wikimedia\Rdbms\IConnectionProvider;
29use Wikimedia\Rdbms\IExpression;
30use Wikimedia\Rdbms\IReadableDatabase;
31use Wikimedia\Rdbms\LikeValue;
32use Wikimedia\Rdbms\RawSQLValue;
33use Wikimedia\Rdbms\SelectQueryBuilder;
34
35/**
36 * Read-write access to the change_tags table.
37 *
38 * This class also provides a limited functionality for querying tags on remote wikis.
39 *
40 * @since 1.41
41 * @ingroup ChangeTags
42 */
43class ChangeTagsStore {
44
45    /**
46     * Name of change_tag table
47     */
48    private const CHANGE_TAG = 'change_tag';
49
50    /**
51     * Name of change_tag_def table
52     */
53    private const CHANGE_TAG_DEF = 'change_tag_def';
54
55    public const DISPLAY_TABLE_ALIAS = 'changetagdisplay';
56
57    /**
58     * @internal For use by ServiceWiring
59     */
60    public const CONSTRUCTOR_OPTIONS = [
61        MainConfigNames::SoftwareTags,
62        MainConfigNames::UseTagFilter,
63        MainConfigNames::RestrictedTagViewRights,
64    ];
65
66    /**
67     * A list of tags defined and used by MediaWiki itself.
68     */
69    private const DEFINED_SOFTWARE_TAGS = [
70        'mw-contentmodelchange',
71        'mw-new-redirect',
72        'mw-removed-redirect',
73        'mw-changed-redirect-target',
74        'mw-blank',
75        'mw-replace',
76        'mw-recreated',
77        'mw-rollback',
78        'mw-undo',
79        'mw-manual-revert',
80        'mw-reverted',
81        'mw-server-side-upload',
82        'mw-ipblock-appeal',
83        'mw-edited-other-users-js',
84        'mw-edited-other-users-css',
85    ];
86
87    /**
88     * Reserved name prefix for rights-restricted change tags. A tag whose name
89     * starts with this prefix cannot be defined by users and are only visible
90     * to users with the required right.
91     *
92     * @internal Use {@link ChangeTagsStore::isRestrictedTag()} to check if a tag is restricted.
93     *   Only public for use by {@link ChangeTags}.
94     */
95    public const string PRIVATE_TAG_PREFIX = 'mw-private-';
96
97    private IConnectionProvider $dbProvider;
98    private LoggerInterface $logger;
99    private ServiceOptions $options;
100    private NameTableStore $changeTagDefStore;
101    private WANObjectCache $wanCache;
102    private HookRunner $hookRunner;
103    private UserFactory $userFactory;
104    private HookContainer $hookContainer;
105    private string|false $wiki;
106
107    /** @var array<string,string[]>|null Instance cache of the map of restricted tags to the rights */
108    private ?array $restrictedTagRights = null;
109
110    public function __construct(
111        IConnectionProvider $dbProvider,
112        NameTableStore $changeTagDefStore,
113        WANObjectCache $wanCache,
114        HookContainer $hookContainer,
115        LoggerInterface $logger,
116        UserFactory $userFactory,
117        ServiceOptions $options,
118        string|false $wiki = false
119    ) {
120        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
121        $this->dbProvider = $dbProvider;
122        $this->logger = $logger;
123        $this->options = $options;
124        $this->changeTagDefStore = $changeTagDefStore;
125        $this->wanCache = $wanCache;
126        $this->hookContainer = $hookContainer;
127        $this->userFactory = $userFactory;
128        $this->hookRunner = new HookRunner( $hookContainer );
129        $this->wiki = $wiki;
130    }
131
132    /**
133     * Loads defined core tags, checks for invalid types (if not array),
134     * and filters for supported and enabled (if $all is false) tags only.
135     *
136     * This operation is supported only for the local wiki.
137     *
138     * @param bool $all If true, return all valid defined tags. Otherwise, return only enabled ones.
139     * @return array Array of all defined/enabled tags.
140     */
141    public function getSoftwareTags( $all = false ): array {
142        if ( $this->wiki !== false ) {
143            throw new LogicException( 'ChangeTagsStore does not support listing software tags on remote wikis' );
144        }
145
146        $coreTags = $this->options->get( MainConfigNames::SoftwareTags );
147        if ( !is_array( $coreTags ) ) {
148            $this->logger->warning( 'wgSoftwareTags should be associative array of enabled tags.
149            Please refer to documentation for the list of tags you can enable' );
150            return [];
151        }
152
153        $availableSoftwareTags = !$all ?
154            array_keys( array_filter( $coreTags ) ) :
155            array_keys( $coreTags );
156
157        return array_intersect(
158            $availableSoftwareTags,
159            self::DEFINED_SOFTWARE_TAGS
160        );
161    }
162
163    /**
164     * Expose the codebase-level defined software tags.
165     * No filtering is available for this function.
166     *
167     * @return array Array of all core-defined tags
168     */
169    public function getCoreDefinedTags(): array {
170        return self::DEFINED_SOFTWARE_TAGS;
171    }
172
173    /**
174     * Return all the tags associated with the given recent change ID,
175     * revision ID, and/or log entry ID, along with any data stored with the tag.
176     *
177     * Does not remove tags that the viewing user cannot see, use {@link self::filterViewableTags} on the array
178     * keys to filter for tags that the user can see.
179     *
180     * @param IReadableDatabase $db the database to query
181     * @param int|null $rc_id
182     * @param int|null $rev_id
183     * @param int|null $log_id
184     * @return string[] Tag name => data. Data format is tag-specific.
185     * @since 1.41
186     */
187    public function getTagsWithData(
188        IReadableDatabase $db, $rc_id = null, $rev_id = null, $log_id = null
189    ): array {
190        if ( !$rc_id && !$rev_id && !$log_id ) {
191            throw new InvalidArgumentException(
192                'At least one of: RCID, revision ID, and log ID MUST be ' .
193                'specified when loading tags from a change!' );
194        }
195
196        $conds = array_filter(
197            [
198                'ct_rc_id' => $rc_id,
199                'ct_rev_id' => $rev_id,
200                'ct_log_id' => $log_id,
201            ]
202        );
203        $result = $db->newSelectQueryBuilder()
204            ->select( [ 'ct_tag_id', 'ct_params' ] )
205            ->from( self::CHANGE_TAG )
206            ->where( $conds )
207            ->caller( __METHOD__ )
208            ->fetchResultSet();
209
210        $tags = [];
211        foreach ( $result as $row ) {
212            $tagName = $this->changeTagDefStore->getName( (int)$row->ct_tag_id );
213            $tags[$tagName] = $row->ct_params;
214        }
215
216        return $tags;
217    }
218
219    /**
220     * Make the tag summary subquery based on the given tables and return it.
221     *
222     * Restricted tags (the mw-private- prefix) that the given performer may not view are excluded;
223     * a null performer excludes all of them.
224     *
225     * @param string|array $tables Table names, see Database::select
226     * @param Authority|null $performer Viewer, for restricted-tag access checks. Null hides all
227     *   restricted tags. (since 1.47); passing null is deprecated since 1.47
228     *
229     * @return string tag summary subquery
230     */
231    public function makeTagSummarySubquery( $tables, ?Authority $performer = null ): string {
232        if ( $performer === null ) {
233            wfDeprecatedMsg( __METHOD__ . ' without an Authority $performer is deprecated', '1.47' );
234        }
235
236        return $this->buildTagSummarySubquery( $tables, $performer ?? self::getAuthorityForPublicTags() );
237    }
238
239    /**
240     * makeTagSummarySubquery() without the null-performer deprecation, for internal callers.
241     *
242     * @param string|array $tables Table names, see Database::select
243     * @param Authority $performer Viewer, for restricted-tag access checks
244     * @return string
245     */
246    private function buildTagSummarySubquery( $tables, Authority $performer ): string {
247        // Normalize to arrays
248        $tables = (array)$tables;
249
250        // Figure out which ID field to use
251        if ( in_array( 'recentchanges', $tables ) ) {
252            $join_cond = 'ct_rc_id=rc_id';
253        } elseif ( in_array( 'logging', $tables ) ) {
254            $join_cond = 'ct_log_id=log_id';
255        } elseif ( in_array( 'revision', $tables ) ) {
256            $join_cond = 'ct_rev_id=rev_id';
257        } elseif ( in_array( 'archive', $tables ) ) {
258            $join_cond = 'ct_rev_id=ar_rev_id';
259        } else {
260            throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
261        }
262
263        $dbr = $this->dbProvider->getReplicaDatabase( $this->wiki );
264        $queryBuilder = $dbr->newSelectQueryBuilder()
265            ->table( self::CHANGE_TAG )
266            ->join( self::CHANGE_TAG_DEF, null, 'ct_tag_id=ctd_id' )
267            ->field( 'ctd_name' )
268            ->where( $join_cond );
269
270        // Exclude restricted tags by prefix; re-include the ones the performer may view.
271        $restrictedCond = $dbr->expr(
272            'ctd_name', IExpression::NOT_LIKE, new LikeValue( self::PRIVATE_TAG_PREFIX, $dbr->anyString() )
273        );
274        $viewable = $this->filterViewableTags( array_keys( $this->getRestrictedTagRights() ), $performer );
275        if ( $viewable ) {
276            $restrictedCond = $dbr->orExpr( [ $restrictedCond, $dbr->expr( 'ctd_name', '=', $viewable ) ] );
277        }
278        $queryBuilder->andWhere( $restrictedCond );
279
280        return $queryBuilder->buildGroupConcatField( ',' );
281    }
282
283    /**
284     * Set ctd_user_defined = 1 in change_tag_def without checking that the tag name is valid.
285     * Extensions should NOT use this function; they can use the ListDefinedTags
286     * hook instead.
287     *
288     * This operation is supported only for the local wiki.
289     *
290     * @param string $tag Tag to create
291     * @since 1.41
292     */
293    public function defineTag( $tag ) {
294        if ( $this->wiki !== false ) {
295            throw new LogicException( 'ChangeTagsStore does not support defining tags on remote wikis' );
296        }
297
298        $dbw = $this->dbProvider->getPrimaryDatabase();
299        $dbw->newInsertQueryBuilder()
300            ->insertInto( self::CHANGE_TAG_DEF )
301            ->row( [
302                'ctd_name' => $tag,
303                'ctd_user_defined' => 1,
304                'ctd_count' => 0
305            ] )
306            ->onDuplicateKeyUpdate()
307            ->uniqueIndexFields( [ 'ctd_name' ] )
308            ->set( [ 'ctd_user_defined' => 1 ] )
309            ->caller( __METHOD__ )->execute();
310
311        // clear the memcache of defined tags
312        $this->purgeTagCacheAll();
313    }
314
315    /**
316     * Update ctd_user_defined = 0 field in change_tag_def.
317     * The tag may remain in use by extensions, and may still show up as 'defined'
318     * if an extension is setting it from the ListDefinedTags hook.
319     *
320     * This operation is supported only for the local wiki.
321     *
322     * @param string $tag Tag to remove
323     * @since 1.41
324     */
325    public function undefineTag( $tag ) {
326        if ( $this->wiki !== false ) {
327            throw new LogicException( 'ChangeTagsStore does not support undefining tags on remote wikis' );
328        }
329
330        $dbw = $this->dbProvider->getPrimaryDatabase();
331
332        $dbw->newUpdateQueryBuilder()
333            ->update( self::CHANGE_TAG_DEF )
334            ->set( [ 'ctd_user_defined' => 0 ] )
335            ->where( [ 'ctd_name' => $tag ] )
336            ->caller( __METHOD__ )->execute();
337
338        $dbw->newDeleteQueryBuilder()
339            ->deleteFrom( self::CHANGE_TAG_DEF )
340            ->where( [ 'ctd_name' => $tag, 'ctd_count' => 0 ] )
341            ->caller( __METHOD__ )->execute();
342
343        // clear the memcache of defined tags
344        $this->purgeTagCacheAll();
345    }
346
347    /**
348     * Writes a tag action into the tag management log.
349     *
350     * This operation is supported only for the local wiki.
351     *
352     * @param string $action
353     * @param string $tag
354     * @param string $reason
355     * @param UserIdentity $user Who to attribute the action to
356     * @param int|null $tagCount For deletion only, how many usages the tag had before
357     * it was deleted.
358     * @param array $logEntryTags Change tags to apply to the entry
359     * that will be created in the tag management log
360     * @return int ID of the inserted log entry
361     * @since 1.41
362     */
363    public function logTagManagementAction( string $action, string $tag, string $reason,
364        UserIdentity $user, $tagCount = null, array $logEntryTags = []
365    ) {
366        if ( $this->wiki !== false ) {
367            throw new LogicException( 'ChangeTagsStore does not support logging actions on remote wikis' );
368        }
369        $dbw = $this->dbProvider->getPrimaryDatabase();
370
371        $logEntry = new ManualLogEntry( 'managetags', $action );
372        $logEntry->setPerformer( $user );
373        // target page is not relevant, but it has to be set, so we just put in
374        // the title of Special:Tags
375        $logEntry->setTarget( Title::newFromText( 'Special:Tags' ) );
376        $logEntry->setComment( $reason );
377
378        $params = [ '4::tag' => $tag ];
379        if ( $tagCount !== null ) {
380            $params['5:number:count'] = $tagCount;
381        }
382        $logEntry->setParameters( $params );
383        $logEntry->setRelations( [ 'Tag' => $tag ] );
384        $logEntry->addTags( $logEntryTags );
385
386        $logId = $logEntry->insert( $dbw );
387        $logEntry->publish( $logId );
388        return $logId;
389    }
390
391    /**
392     * Permanently removes all traces of a tag from the DB. Good for removing
393     * misspelt or temporary tags.
394     *
395     * This function should be directly called by maintenance scripts only, never
396     * by user-facing code. See deleteTagWithChecks() for functionality that can
397     * safely be exposed to users.
398     *
399     * This operation is supported only for the local wiki.
400     *
401     * @param string $tag Tag to remove
402     * @return Status The returned status will be good unless a hook changed it
403     * @since 1.41
404     */
405    public function deleteTagEverywhere( $tag ) {
406        if ( $this->wiki !== false ) {
407            throw new LogicException( 'ChangeTagsStore does not support deleting tags on remote wikis' );
408        }
409
410        $dbw = $this->dbProvider->getPrimaryDatabase();
411        $dbw->startAtomic( __METHOD__ );
412
413        // fetch tag id, this must be done before calling undefineTag(), see T225564
414        $tagId = $this->changeTagDefStore->getId( $tag );
415
416        // set ctd_user_defined = 0
417        $this->undefineTag( $tag );
418
419        // delete from change_tag
420        $dbw->newDeleteQueryBuilder()
421            ->deleteFrom( self::CHANGE_TAG )
422            ->where( [ 'ct_tag_id' => $tagId ] )
423            ->caller( __METHOD__ )->execute();
424        $dbw->newDeleteQueryBuilder()
425            ->deleteFrom( self::CHANGE_TAG_DEF )
426            ->where( [ 'ctd_name' => $tag ] )
427            ->caller( __METHOD__ )->execute();
428        $dbw->endAtomic( __METHOD__ );
429
430        // give extensions a chance
431        $status = Status::newGood();
432        $this->hookRunner->onChangeTagAfterDelete( $tag, $status );
433        // let's not allow error results, as the actual tag deletion succeeded
434        if ( !$status->isOK() ) {
435            $this->logger->debug( 'ChangeTagAfterDelete error condition downgraded to warning' );
436            $status->setOK( true );
437        }
438
439        // clear the memcache of defined tags
440        $this->purgeTagCacheAll();
441
442        return $status;
443    }
444
445    /**
446     * Invalidates the short-term cache of defined tags used by the
447     * list*DefinedTags functions, as well as the tag statistics cache.
448     * @since 1.41
449     */
450    public function purgeTagCacheAll() {
451        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'active-tags', $this->wiki ) );
452        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'valid-tags-db', $this->wiki ) );
453        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'valid-tags-hook', $this->wiki ) );
454        $this->wanCache->touchCheckKey( $this->wanCache->makeKey( 'tags-usage-statistics', $this->wiki ) );
455
456        $this->changeTagDefStore->reloadMap();
457    }
458
459    /**
460     * Returns a map of any tags used on the wiki to number of edits
461     * tagged with them, ordered descending by the hitcount.
462     * This does not include tags defined somewhere that have never been applied.
463     * @return array Array of string => int
464     */
465    public function tagUsageStatistics(): array {
466        $fname = __METHOD__;
467        $dbProvider = $this->dbProvider;
468        $wiki = $this->wiki;
469
470        return $this->wanCache->getWithSetCallback(
471            $this->wanCache->makeKey( 'tags-usage-statistics', $wiki ),
472            WANObjectCache::TTL_HOUR,
473            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbProvider, $wiki ) {
474                $dbr = $dbProvider->getReplicaDatabase( $wiki );
475                $res = $dbr->newSelectQueryBuilder()
476                    ->select( [ 'ctd_name', 'ctd_count' ] )
477                    ->from( self::CHANGE_TAG_DEF )
478                    ->orderBy( 'ctd_count', SelectQueryBuilder::SORT_DESC )
479                    ->caller( $fname )
480                    ->fetchResultSet();
481
482                $out = [];
483                foreach ( $res as $row ) {
484                    $out[$row->ctd_name] = $row->ctd_count;
485                }
486
487                return $out;
488            },
489            [
490                'checkKeys' => [ $this->wanCache->makeKey( 'tags-usage-statistics', $this->wiki ) ],
491                'lockTSE' => WANObjectCache::TTL_HOUR,
492                'pcTTL' => WANObjectCache::TTL_PROC_LONG
493            ]
494        );
495    }
496
497    /**
498     * Lists tags explicitly defined in the `change_tag_def` table of the database.
499     *
500     * Tries memcached first.
501     *
502     * @return string[] Array of strings: tags
503     * @since 1.25
504     */
505    public function listExplicitlyDefinedTags() {
506        $fname = __METHOD__;
507        $dbProvider = $this->dbProvider;
508        $wiki = $this->wiki;
509
510        return $this->wanCache->getWithSetCallback(
511            $this->wanCache->makeKey( 'valid-tags-db', $wiki ),
512            WANObjectCache::TTL_HOUR,
513            static function () use ( $fname, $dbProvider, $wiki ) {
514                $dbr = $dbProvider->getReplicaDatabase( $wiki );
515                $tags = $dbr->newSelectQueryBuilder()
516                    ->select( 'ctd_name' )
517                    ->from( self::CHANGE_TAG_DEF )
518                    ->where( [ 'ctd_user_defined' => 1 ] )
519                    ->caller( $fname )
520                    ->fetchFieldValues();
521
522                return array_unique( $tags );
523            },
524            [
525                'checkKeys' => [ $this->wanCache->makeKey( 'valid-tags-db', $this->wiki ) ],
526                'lockTSE' => WANObjectCache::TTL_HOUR,
527                'pcTTL' => WANObjectCache::TTL_PROC_LONG
528            ]
529        );
530    }
531
532    /**
533     * Lists tags defined by core or extensions using the ListDefinedTags hook.
534     * Extensions need only define those tags they deem to be in active use.
535     *
536     * Tries memcached first.
537     *
538     * @return string[] Array of strings: tags
539     * @since 1.25
540     */
541    public function listSoftwareDefinedTags() {
542        // core defined tags
543        $tags = $this->getSoftwareTags( true );
544        if ( !$this->hookContainer->isRegistered( 'ListDefinedTags' ) ) {
545            return $tags;
546        }
547        $hookRunner = $this->hookRunner;
548        $wiki = $this->wiki;
549        return $this->wanCache->getWithSetCallback(
550            $this->wanCache->makeKey( 'valid-tags-hook', $wiki ),
551            WANObjectCache::TTL_HOUR,
552            static function () use ( $tags, $hookRunner ) {
553                $hookRunner->onListDefinedTags( $tags );
554                return array_unique( $tags );
555            },
556            [
557                'checkKeys' => [ $this->wanCache->makeKey( 'valid-tags-hook', $this->wiki ) ],
558                'lockTSE' => WANObjectCache::TTL_HOUR,
559                'pcTTL' => WANObjectCache::TTL_PROC_LONG
560            ]
561        );
562    }
563
564    /**
565     * Return all the tags associated with the given recent change ID, revision ID, and/or log entry ID.
566     *
567     * Does not remove tags that the viewing user cannot see, use {@link self::getViewableTags} for that
568     * instead.
569     *
570     * @param IReadableDatabase $db the database to query
571     * @param int|null $rc_id
572     * @param int|null $rev_id
573     * @param int|null $log_id
574     * @return string[]
575     */
576    public function getTags( IReadableDatabase $db, $rc_id = null, $rev_id = null, $log_id = null ) {
577        return array_keys( $this->getTagsWithData( $db, $rc_id, $rev_id, $log_id ) );
578    }
579
580    /**
581     * Return all the tags viewable by the provided {@link Authority} that are associated with the given
582     * recent change ID, revision ID, and/or log entry ID.
583     *
584     * To get all the tags use {@link self::getTags} instead.
585     *
586     * @since 1.47
587     * @param IReadableDatabase $db
588     * @param Authority $performer
589     * @param int|null $recentChangeId
590     * @param int|null $revisionId
591     * @param int|null $logId
592     * @return string[]
593     */
594    public function getViewableTags(
595        IReadableDatabase $db,
596        Authority $performer,
597        ?int $recentChangeId = null,
598        ?int $revisionId = null,
599        ?int $logId = null
600    ): array {
601        return $this->filterViewableTags( $this->getTags( $db, $recentChangeId, $revisionId, $logId ), $performer );
602    }
603
604    /**
605     * Basically lists defined tags which count even if they aren't applied to anything.
606     * It returns a union of the results of listExplicitlyDefinedTags() and
607     * listSoftwareDefinedTags()
608     *
609     * @return string[] Array of strings: tags
610     */
611    public function listDefinedTags() {
612        $tags1 = $this->listExplicitlyDefinedTags();
613        $tags2 = $this->listSoftwareDefinedTags();
614        return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
615    }
616
617    /**
618     * Get tag IDs given a list of tag names. If any name is invalid, its ID
619     * will be omitted from the return value.
620     *
621     * @since 1.45
622     * @param string[] $tagNames
623     * @return array<string,int>
624     */
625    public function getTagIdsFromNames( $tagNames ) {
626        $tagIds = [];
627        foreach ( $tagNames as $name ) {
628            try {
629                $tagIds[$name] = $this->changeTagDefStore->getId( $name );
630            } catch ( NameTableAccessException ) {
631            }
632        }
633        return $tagIds;
634    }
635
636    /**
637     * Returns whether the given tag name is a restricted (private-prefixed) tag.
638     *
639     * @since 1.47
640     */
641    public function isRestrictedTag( string $tag ): bool {
642        return str_starts_with( $tag, self::PRIVATE_TAG_PREFIX );
643    }
644
645    /**
646     * Map of restricted tag name to the right(s) required to view it, as
647     * declared by the ListRestrictedTags hook and the RestrictedTagViewRights
648     * config (which overrides the hook). Cached for the request.
649     *
650     * @return array<string,string[]>
651     */
652    private function getRestrictedTagRights(): array {
653        if ( $this->restrictedTagRights === null ) {
654            $restrictedTags = [];
655            $this->hookRunner->onListRestrictedTags( $restrictedTags );
656            $restrictedTags = array_merge(
657                $restrictedTags,
658                $this->options->get( MainConfigNames::RestrictedTagViewRights )
659            );
660
661            $this->restrictedTagRights = [];
662            foreach ( $restrictedTags as $tag => $rights ) {
663                if ( !$this->isRestrictedTag( $tag ) ) {
664                    $this->logger->error(
665                        'Restricted tags map contains tag {tag} without the required prefix; ignoring',
666                        [ 'tag' => $tag ]
667                    );
668                    continue;
669                }
670                $this->restrictedTagRights[$tag] = (array)$rights;
671            }
672        }
673        return $this->restrictedTagRights;
674    }
675
676    /**
677     * Determine whether the given {@link Authority} may view the provided tag.
678     *
679     * @since 1.47
680     */
681    public function canViewTag( string $tag, Authority $performer ): bool {
682        if ( !$this->isRestrictedTag( $tag ) ) {
683            return true;
684        }
685
686        $rights = $this->getRestrictedTagRights()[$tag] ?? [];
687        foreach ( $rights as $right ) {
688            if ( $performer->isAllowed( $right ) ) {
689                return true;
690            }
691        }
692        return false;
693    }
694
695    /**
696     * Filter a list of tag names down to those the performer may view. Does not query the database.
697     *
698     * @since 1.47
699     * @param string[] $tags
700     * @param Authority $performer
701     * @return string[]
702     */
703    public function filterViewableTags( array $tags, Authority $performer ): array {
704        return array_values( array_filter(
705            $tags,
706            fn ( string $tag ) => !$this->isRestrictedTag( $tag ) || $this->canViewTag( $tag, $performer )
707        ) );
708    }
709
710    /**
711     * @internal Transitional, until all callers pass a performer.
712     *
713     * Like filterViewableTags(), but a null performer may view no restricted
714     * tags at all.
715     *
716     * @param string[] $tags
717     * @param Authority|null $performer
718     * @return string[]
719     */
720    public function filterViewableTagsForPerformer( array $tags, ?Authority $performer ): array {
721        return $this->filterViewableTags( $tags, $performer ?? self::getAuthorityForPublicTags() );
722    }
723
724    /** @return Authority allowed to view no rights-restricted tags, for external-facing public queries. */
725    private static function getAuthorityForPublicTags(): Authority {
726        return new SimpleAuthority( UserIdentityValue::newAnonymous( '127.0.0.1' ), [] );
727    }
728
729    /**
730     * Add and remove tags to/from a change given its rc_id, rev_id and/or log_id,
731     * without verifying that the tags exist or are valid. If a tag is present in
732     * both $tagsToAdd and $tagsToRemove, it will be removed.
733     *
734     * This function should only be used by extensions to manipulate tags they
735     * have registered using the ListDefinedTags hook. When dealing with user
736     * input, call updateTagsWithChecks() instead.
737     *
738     * This operation is supported only for the local wiki.
739     *
740     * @param string|array|null $tagsToAdd Tags to add to the change
741     * @param string|array|null $tagsToRemove Tags to remove from the change
742     * @param int|null &$rc_id The rc_id of the change to add the tags to.
743     * Pass a variable whose value is null if the rc_id is not relevant or unknown.
744     * @param int|null &$rev_id The rev_id of the change to add the tags to.
745     * Pass a variable whose value is null if the rev_id is not relevant or unknown.
746     * @param int|null &$log_id The log_id of the change to add the tags to.
747     * Pass a variable whose value is null if the log_id is not relevant or unknown.
748     * @param string|null $params Params to put in the ct_params field of table
749     * 'change_tag' when adding tags
750     * @param RecentChange|null $rc Recent change being tagged, in case the tagging accompanies
751     * the action
752     * @param UserIdentity|null $user Tagging user, in case the tagging is subsequent to the tagged action
753     *
754     * @return array Index 0 is an array of tags actually added, index 1 is an
755     * array of tags actually removed, index 2 is an array of tags present on the
756     * revision or log entry before any changes were made
757     */
758    public function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id = null,
759        &$rev_id = null, &$log_id = null, $params = null, ?RecentChange $rc = null,
760        ?UserIdentity $user = null
761    ) {
762        if ( $this->wiki !== false ) {
763            throw new LogicException( 'ChangeTagsStore does not support updating tags on remote wikis' );
764        }
765
766        $tagsToAdd = array_filter(
767            (array)$tagsToAdd, // Make sure we're submitting all tags...
768            static function ( $value ) {
769                return ( $value ?? '' ) !== '';
770            }
771        );
772        $tagsToRemove = array_filter(
773            (array)$tagsToRemove,
774            static function ( $value ) {
775                return ( $value ?? '' ) !== '';
776            }
777        );
778
779        if ( !$rc_id && !$rev_id && !$log_id ) {
780            throw new InvalidArgumentException( 'At least one of: RCID, revision ID, and log ID MUST be ' .
781                'specified when adding or removing a tag from a change!' );
782        }
783
784        $dbw = $this->dbProvider->getPrimaryDatabase();
785
786        // Might as well look for rcids and so on.
787        if ( !$rc_id ) {
788            // Info might be out of date, somewhat fractionally, on replica DB.
789            // LogEntry/LogPage and WikiPage match rev/log/rc timestamps,
790            // so use that relation to avoid full table scans.
791            if ( $log_id ) {
792                $rc_id = $dbw->newSelectQueryBuilder()
793                    ->select( 'rc_id' )
794                    ->from( 'logging' )
795                    ->join( 'recentchanges', null, [
796                        'rc_timestamp = log_timestamp',
797                        'rc_logid = log_id'
798                    ] )
799                    ->where( [ 'log_id' => $log_id ] )
800                    ->caller( __METHOD__ )
801                    ->fetchField();
802            } elseif ( $rev_id ) {
803                $rc_id = $dbw->newSelectQueryBuilder()
804                    ->select( 'rc_id' )
805                    ->from( 'revision' )
806                    ->join( 'recentchanges', null, [
807                        'rc_this_oldid = rev_id'
808                    ] )
809                    ->where( [ 'rev_id' => $rev_id ] )
810                    ->caller( __METHOD__ )
811                    ->fetchField();
812            }
813        } elseif ( !$log_id && !$rev_id ) {
814            // Info might be out of date, somewhat fractionally, on replica DB.
815            $log_id = $dbw->newSelectQueryBuilder()
816                ->select( 'rc_logid' )
817                ->from( 'recentchanges' )
818                ->where( [ 'rc_id' => $rc_id ] )
819                ->caller( __METHOD__ )
820                ->fetchField();
821            $rev_id = $dbw->newSelectQueryBuilder()
822                ->select( 'rc_this_oldid' )
823                ->from( 'recentchanges' )
824                ->where( [ 'rc_id' => $rc_id ] )
825                ->caller( __METHOD__ )
826                ->fetchField();
827        }
828
829        if ( $log_id && !$rev_id ) {
830            $rev_id = $dbw->newSelectQueryBuilder()
831                ->select( 'ls_value' )
832                ->from( 'log_search' )
833                ->where( [ 'ls_field' => 'associated_rev_id', 'ls_log_id' => $log_id ] )
834                ->caller( __METHOD__ )
835                ->fetchField();
836        } elseif ( !$log_id && $rev_id ) {
837            $log_id = $dbw->newSelectQueryBuilder()
838                ->select( 'ls_log_id' )
839                ->from( 'log_search' )
840                ->where( [ 'ls_field' => 'associated_rev_id', 'ls_value' => (string)$rev_id ] )
841                ->caller( __METHOD__ )
842                ->fetchField();
843        }
844
845        $prevTags = $this->getTags( $dbw, $rc_id, $rev_id, $log_id );
846
847        // add tags
848        $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
849        $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
850
851        // remove tags
852        $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
853        $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
854
855        sort( $prevTags );
856        sort( $newTags );
857        if ( $prevTags == $newTags ) {
858            return [ [], [], $prevTags ];
859        }
860
861        // insert a row into change_tag for each new tag
862        if ( count( $tagsToAdd ) ) {
863            $changeTagMapping = [];
864            foreach ( $tagsToAdd as $tag ) {
865                $changeTagMapping[$tag] = $this->changeTagDefStore->acquireId( $tag );
866            }
867            $fname = __METHOD__;
868            foreach ( $tagsToAdd as $tag ) {
869                // Filter so we don't insert NULLs as zero accidentally.
870                // Keep in mind that $rc_id === null means "I don't care/know about the
871                // rc_id, just delete $tag on this revision/log entry". It doesn't
872                // mean "only delete tags on this revision/log WHERE rc_id IS NULL".
873                $tagRow = array_filter(
874                    [
875                        'ct_rc_id' => $rc_id,
876                        'ct_log_id' => $log_id,
877                        'ct_rev_id' => $rev_id,
878                        'ct_params' => $params,
879                        'ct_tag_id' => $changeTagMapping[$tag] ?? null,
880                    ]
881                );
882
883                $dbw->newInsertQueryBuilder()
884                    ->insertInto( self::CHANGE_TAG )
885                    ->ignore()
886                    ->row( $tagRow )
887                    ->caller( __METHOD__ )->execute();
888                if ( $dbw->affectedRows() ) {
889                    // T207881: update the counts at the end of the transaction
890                    $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tag, $fname ) {
891                        $dbw->newUpdateQueryBuilder()
892                            ->update( self::CHANGE_TAG_DEF )
893                            ->set( [ 'ctd_count' => new RawSQLValue( 'ctd_count + 1' ) ] )
894                            ->where( [ 'ctd_name' => $tag ] )
895                            ->caller( $fname )->execute();
896                    }, $fname );
897                }
898            }
899        }
900
901        // delete from change_tag
902        if ( count( $tagsToRemove ) ) {
903            $fname = __METHOD__;
904            foreach ( $tagsToRemove as $tag ) {
905                $conds = array_filter(
906                    [
907                        'ct_rc_id' => $rc_id,
908                        'ct_log_id' => $log_id,
909                        'ct_rev_id' => $rev_id,
910                        'ct_tag_id' => $this->changeTagDefStore->getId( $tag ),
911                    ]
912                );
913                $dbw->newDeleteQueryBuilder()
914                    ->deleteFrom( self::CHANGE_TAG )
915                    ->where( $conds )
916                    ->caller( __METHOD__ )->execute();
917                if ( $dbw->affectedRows() ) {
918                    // T207881: update the counts at the end of the transaction
919                    $dbw->onTransactionPreCommitOrIdle( static function () use ( $dbw, $tag, $fname ) {
920                        $dbw->newUpdateQueryBuilder()
921                            ->update( self::CHANGE_TAG_DEF )
922                            ->set( [ 'ctd_count' => new RawSQLValue( 'ctd_count - 1' ) ] )
923                            ->where( [ 'ctd_name' => $tag ] )
924                            ->caller( $fname )->execute();
925
926                        $dbw->newDeleteQueryBuilder()
927                            ->deleteFrom( self::CHANGE_TAG_DEF )
928                            ->where( [ 'ctd_name' => $tag, 'ctd_count' => 0, 'ctd_user_defined' => 0 ] )
929                            ->caller( $fname )->execute();
930                    }, $fname );
931                }
932            }
933        }
934
935        $userObj = $user ? $this->userFactory->newFromUserIdentity( $user ) : null;
936        $this->hookRunner->onChangeTagsAfterUpdateTags(
937            $tagsToAdd, $tagsToRemove, $prevTags, $rc_id, $rev_id, $log_id, $params, $rc, $userObj );
938
939        return [ $tagsToAdd, $tagsToRemove, $prevTags ];
940    }
941
942    /**
943     * Add tags to a change given its rc_id, rev_id and/or log_id
944     *
945     * @param string|string[] $tags Tags to add to the change
946     * @param int|null $rc_id The rc_id of the change to add the tags to
947     * @param int|null $rev_id The rev_id of the change to add the tags to
948     * @param int|null $log_id The log_id of the change to add the tags to
949     * @param string|null $params Params to put in the ct_params field of table 'change_tag'
950     * @param RecentChange|null $rc Recent change, in case the tagging accompanies the action
951     * (this should normally be the case)
952     *
953     * @return bool False if no changes are made, otherwise true
954     */
955    public function addTags( $tags, $rc_id = null, $rev_id = null,
956        $log_id = null, $params = null, ?RecentChange $rc = null
957    ) {
958        $result = $this->updateTags( $tags, null, $rc_id, $rev_id, $log_id, $params, $rc );
959        return (bool)$result[0];
960    }
961
962    /**
963     * Lists those tags which core or extensions report as being "active".
964     *
965     * @return array
966     * @since 1.41
967     */
968    public function listSoftwareActivatedTags() {
969        // core active tags
970        $tags = $this->getSoftwareTags();
971        if ( !$this->hookContainer->isRegistered( 'ChangeTagsListActive' ) ) {
972            return $tags;
973        }
974        $hookRunner = $this->hookRunner;
975        $wiki = $this->wiki;
976
977        return $this->wanCache->getWithSetCallback(
978            $this->wanCache->makeKey( 'active-tags', $wiki ),
979            WANObjectCache::TTL_HOUR,
980            static function () use ( $tags, $hookRunner ) {
981                // Ask extensions which tags they consider active
982                $hookRunner->onChangeTagsListActive( $tags );
983                return $tags;
984            },
985            [
986                'checkKeys' => [ $this->wanCache->makeKey( 'active-tags', $this->wiki ) ],
987                'lockTSE' => WANObjectCache::TTL_HOUR,
988                'pcTTL' => WANObjectCache::TTL_PROC_LONG
989            ]
990        );
991    }
992
993    /**
994     * Applies all tags-related changes to a query.
995     * Handles selecting tags, and filtering.
996     * Needs $tables to be set up properly, so we can figure out which join conditions to use.
997     *
998     * When querying table from a remote wiki, this function honors the local wiki's UseTagFilter
999     * configuration setting.
1000     *
1001     * Restricted tags are always hidden from this method's output and cannot be filtered on; use
1002     * addTagsToDisplayQuery() instead.
1003     *
1004     * WARNING: If $filter_tag contains more than one tag and $exclude is false, this function
1005     * will add DISTINCT, which may cause performance problems for your query unless you put
1006     * the ID field of your table at the end of the ORDER BY, and set a GROUP BY equal to the
1007     * ORDER BY. For example, if you had ORDER BY foo_timestamp DESC, you will now need
1008     * GROUP BY foo_timestamp, foo_id ORDER BY foo_timestamp DESC, foo_id DESC.
1009     *
1010     * @deprecated since 1.41 use ChangeTagsStore::addTagsToDisplayQuery instead
1011     *
1012     * @param string|array &$tables Table names, see Database::select
1013     * @param string|array &$fields Fields used in query, see Database::select
1014     * @param string|array &$conds Conditions used in query, see Database::select
1015     * @param array &$join_conds Join conditions, see Database::select
1016     * @param string|array &$options Options, see Database::select
1017     * @param string|array|false|null $filter_tag Tag(s) to select on (OR)
1018     * @param bool $exclude If true, exclude tag(s) from $filter_tag (NOR)
1019     */
1020    public function modifyDisplayQuery( &$tables, &$fields, &$conds,
1021        &$join_conds, &$options, $filter_tag = '', bool $exclude = false
1022    ) {
1023        wfDeprecated( __METHOD__, '1.41' );
1024
1025        $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
1026
1027        // Normalize to arrays
1028        $tables = (array)$tables;
1029        $fields = (array)$fields;
1030        $conds = (array)$conds;
1031        $options = (array)$options;
1032
1033        $fields['ts_tags'] = $this->makeTagSummarySubquery( $tables, self::getAuthorityForPublicTags() );
1034        // We use an alias and qualify the conditions in case there are
1035        // multiple joins to this table.
1036        // In particular for compatibility with the RC filters that extension Translate does.
1037
1038        // Figure out which ID field to use
1039        if ( in_array( 'recentchanges', $tables ) ) {
1040            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rc_id=rc_id';
1041        } elseif ( in_array( 'logging', $tables ) ) {
1042            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_log_id=log_id';
1043        } elseif ( in_array( 'revision', $tables ) ) {
1044            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=rev_id';
1045        } elseif ( in_array( 'archive', $tables ) ) {
1046            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=ar_rev_id';
1047        } else {
1048            throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
1049        }
1050
1051        if ( !$useTagFilter ) {
1052            return;
1053        }
1054
1055        if ( !is_array( $filter_tag ) ) {
1056            // some callers provide false or null
1057            $filter_tag = (string)$filter_tag;
1058        }
1059
1060        if ( $filter_tag !== [] && $filter_tag !== '' ) {
1061            // Somebody wants to filter on a tag.
1062            // Add an INNER JOIN on change_tag
1063            $viewableFilterTags = $this->filterViewableTags( (array)$filter_tag, self::getAuthorityForPublicTags() );
1064            $filterTagIds = array_values( $this->getTagIdsFromNames( $viewableFilterTags ) );
1065
1066            if ( $exclude ) {
1067                if ( $filterTagIds !== [] ) {
1068                    $tables[self::DISPLAY_TABLE_ALIAS] = self::CHANGE_TAG;
1069                    $join_conds[self::DISPLAY_TABLE_ALIAS] = [
1070                        'LEFT JOIN',
1071                        [ $join_cond, self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => $filterTagIds ]
1072                    ];
1073                    $conds[self::DISPLAY_TABLE_ALIAS . '.ct_tag_id'] = null;
1074                }
1075            } else {
1076                $tables[self::DISPLAY_TABLE_ALIAS] = self::CHANGE_TAG;
1077                $join_conds[self::DISPLAY_TABLE_ALIAS] = [ 'JOIN', $join_cond ];
1078                if ( $filterTagIds !== [] ) {
1079                    $conds[self::DISPLAY_TABLE_ALIAS . '.ct_tag_id'] = $filterTagIds;
1080                } else {
1081                    // all tags were invalid, return nothing
1082                    $conds[] = '0=1';
1083                }
1084
1085                if (
1086                    count( $viewableFilterTags ) > 1 &&
1087                    !in_array( 'DISTINCT', $options )
1088                ) {
1089                    $options[] = 'DISTINCT';
1090                }
1091            }
1092        }
1093    }
1094
1095    /**
1096     * Applies all tags-related changes to a query builder object.
1097     *
1098     * Handles selecting tags, and filtering.
1099     *
1100     * When querying table from a remote wiki, this function honors the local wiki's UseTagFilter
1101     * configuration setting.
1102     *
1103     * WARNING: If $filterTag contains more than one tag and $exclude is false, this function
1104     * will add DISTINCT, which may cause performance problems for your query unless you put
1105     * the ID field of your table at the end of the ORDER BY, and set a GROUP BY equal to the
1106     * ORDER BY. For example, if you had ORDER BY foo_timestamp DESC, you will now need
1107     * GROUP BY foo_timestamp, foo_id ORDER BY foo_timestamp DESC, foo_id DESC.
1108     *
1109     * @since 1.47
1110     * @param SelectQueryBuilder $queryBuilder Query builder to add the join
1111     * @param string $table Table name. Must be either of 'recentchanges', 'logging', 'revision', or 'archive'
1112     * @param Authority $performer Viewer, for restricted-tag access checks
1113     * @param string|array $filterTag Tag(s) to select on (OR)
1114     * @param bool $exclude If true, exclude tag(s) from $filterTag (NOR)
1115     */
1116    public function addTagsToDisplayQuery(
1117        SelectQueryBuilder $queryBuilder,
1118        string $table,
1119        Authority $performer,
1120        string|array $filterTag = '',
1121        bool $exclude = false
1122    ): void {
1123        $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
1124        $queryBuilder->field( $this->buildTagSummarySubquery( [ $table ], $performer ), 'ts_tags' );
1125
1126        // We use an alias and qualify the conditions in case there are
1127        // multiple joins to this table.
1128        // In particular for compatibility with the RC filters that extension Translate does.
1129        // Figure out which ID field to use
1130        if ( $table === 'recentchanges' ) {
1131            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rc_id=rc_id';
1132        } elseif ( $table === 'logging' ) {
1133            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_log_id=log_id';
1134        } elseif ( $table === 'revision' ) {
1135            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=rev_id';
1136        } elseif ( $table === 'archive' ) {
1137            $join_cond = self::DISPLAY_TABLE_ALIAS . '.ct_rev_id=ar_rev_id';
1138        } else {
1139            throw new InvalidArgumentException( 'Unable to determine appropriate JOIN condition for tagging.' );
1140        }
1141
1142        if ( !$useTagFilter ) {
1143            return;
1144        }
1145
1146        if ( is_string( $filterTag ) ) {
1147            $filterTag = $filterTag === '' ? [] : [ $filterTag ];
1148        }
1149        if ( $filterTag !== [] ) {
1150            // Somebody wants to filter on a tag.
1151            // Add an INNER JOIN on change_tag
1152            $viewableFilterTags = $this->filterViewableTags( $filterTag, $performer );
1153            $filterTagIds = array_values( $this->getTagIdsFromNames( $viewableFilterTags ) );
1154
1155            if ( $exclude ) {
1156                if ( $filterTagIds !== [] ) {
1157                    $queryBuilder->leftJoin(
1158                        self::CHANGE_TAG,
1159                        self::DISPLAY_TABLE_ALIAS,
1160                        [ $join_cond, self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => $filterTagIds ]
1161                    );
1162                    $queryBuilder->where( [ self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => null ] );
1163                }
1164            } else {
1165                $queryBuilder->join(
1166                    self::CHANGE_TAG,
1167                    self::DISPLAY_TABLE_ALIAS,
1168                    $join_cond
1169                );
1170                if ( $filterTagIds !== [] ) {
1171                    $queryBuilder->where( [ self::DISPLAY_TABLE_ALIAS . '.ct_tag_id' => $filterTagIds ] );
1172                } else {
1173                    // all tags were invalid, return nothing
1174                    $queryBuilder->where( '0=1' );
1175                }
1176
1177                if ( count( $viewableFilterTags ) > 1 ) {
1178                    $queryBuilder->distinct();
1179                }
1180            }
1181        }
1182    }
1183
1184    /**
1185     * Applies all tags-related changes to a query builder object.
1186     *
1187     * Handles selecting tags, and filtering. Restricted tags are always hidden from this
1188     * method's output; use addTagsToDisplayQuery() with an Authority instead.
1189     *
1190     * @deprecated since 1.47, use {@link self::addTagsToDisplayQuery()} instead
1191     * @param SelectQueryBuilder $queryBuilder Query builder to add the join
1192     * @param string $table Table name. Must be either of 'recentchanges', 'logging', 'revision', or 'archive'
1193     * @param string|array|false|null $filter_tag Tag(s) to select on (OR)
1194     * @param bool $exclude If true, exclude tag(s) from $filter_tag (NOR)
1195     */
1196    public function modifyDisplayQueryBuilder(
1197        SelectQueryBuilder $queryBuilder,
1198        $table,
1199        $filter_tag = '',
1200        bool $exclude = false
1201    ): void {
1202        wfDeprecated( __METHOD__, '1.47' );
1203
1204        $this->addTagsToDisplayQuery(
1205            $queryBuilder, $table, self::getAuthorityForPublicTags(),
1206            $filter_tag === false || $filter_tag === null ? '' : $filter_tag,
1207            $exclude
1208        );
1209    }
1210}