24use InvalidArgumentException;
36use Psr\Log\LoggerInterface;
54 private const CHANGE_TAG =
'change_tag';
59 private const CHANGE_TAG_DEF =
'change_tag_def';
74 private const DEFINED_SOFTWARE_TAGS = [
75 'mw-contentmodelchange',
77 'mw-removed-redirect',
78 'mw-changed-redirect-target',
85 'mw-server-side-upload',
89 private LoggerInterface $logger;
102 LoggerInterface $logger,
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 );
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' );
132 $availableSoftwareTags = !$all ?
133 array_keys( array_filter( $coreTags ) ) :
134 array_keys( $coreTags );
136 return array_intersect(
137 $availableSoftwareTags,
138 self::DEFINED_SOFTWARE_TAGS
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!' );
162 $conds = array_filter(
164 'ct_rc_id' => $rc_id,
165 'ct_rev_id' => $rev_id,
166 'ct_log_id' => $log_id,
169 $result = $db->newSelectQueryBuilder()
170 ->select( [
'ct_tag_id',
'ct_params' ] )
171 ->from( self::CHANGE_TAG )
173 ->caller( __METHOD__ )
177 foreach ( $result as $row ) {
178 $tagName = $this->changeTagDefStore->getName( (
int)$row->ct_tag_id );
179 $tags[$tagName] = $row->ct_params;
194 $tables = (array)$tables;
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';
206 throw new InvalidArgumentException(
'Unable to determine appropriate JOIN condition for tagging.' );
209 $tagTables = [ self::CHANGE_TAG, self::CHANGE_TAG_DEF ];
210 $join_cond_ts_tags = [ self::CHANGE_TAG_DEF => [
'JOIN',
'ct_tag_id=ctd_id' ] ];
213 return $this->dbProvider->getReplicaDatabase()
214 ->buildGroupConcatField(
',', $tagTables, $field, $join_cond, $join_cond_ts_tags );
226 $dbw = $this->dbProvider->getPrimaryDatabase();
227 $dbw->newInsertQueryBuilder()
228 ->insertInto( self::CHANGE_TAG_DEF )
231 'ctd_user_defined' => 1,
234 ->onDuplicateKeyUpdate()
235 ->uniqueIndexFields( [
'ctd_name' ] )
236 ->set( [
'ctd_user_defined' => 1 ] )
237 ->caller( __METHOD__ )->execute();
240 $this->purgeTagCacheAll();
252 $dbw = $this->dbProvider->getPrimaryDatabase();
254 $dbw->newUpdateQueryBuilder()
255 ->update( self::CHANGE_TAG_DEF )
256 ->set( [
'ctd_user_defined' => 0 ] )
257 ->where( [
'ctd_name' => $tag ] )
258 ->caller( __METHOD__ )->execute();
260 $dbw->newDeleteQueryBuilder()
261 ->deleteFrom( self::CHANGE_TAG_DEF )
262 ->where( [
'ctd_name' => $tag,
'ctd_count' => 0 ] )
263 ->caller( __METHOD__ )->execute();
266 $this->purgeTagCacheAll();
284 UserIdentity $user, $tagCount =
null, array $logEntryTags = []
286 $dbw = $this->dbProvider->getPrimaryDatabase();
289 $logEntry->setPerformer( $user );
292 $logEntry->setTarget( Title::newFromText(
'Special:Tags' ) );
293 $logEntry->setComment( $reason );
295 $params = [
'4::tag' => $tag ];
296 if ( $tagCount !==
null ) {
297 $params[
'5:number:count'] = $tagCount;
299 $logEntry->setParameters(
$params );
300 $logEntry->setRelations( [
'Tag' => $tag ] );
301 $logEntry->addTags( $logEntryTags );
303 $logId = $logEntry->insert( $dbw );
304 $logEntry->publish( $logId );
321 $dbw = $this->dbProvider->getPrimaryDatabase();
322 $dbw->startAtomic( __METHOD__ );
325 $tagId = $this->changeTagDefStore->getId( $tag );
328 $this->undefineTag( $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__ );
342 $status = Status::newGood();
343 $this->hookRunner->onChangeTagAfterDelete( $tag, $status );
345 if ( !$status->isOK() ) {
346 $this->logger->debug(
'ChangeTagAfterDelete error condition downgraded to warning' );
347 $status->setOK(
true );
351 $this->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' ) );
367 $this->changeTagDefStore->reloadMap();
378 $dbProvider = $this->dbProvider;
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 ) {
385 $res = $dbr->newSelectQueryBuilder()
386 ->select( [
'ctd_name',
'ctd_count' ] )
387 ->from( self::CHANGE_TAG_DEF )
388 ->orderBy(
'ctd_count', SelectQueryBuilder::SORT_DESC )
393 foreach ( $res as $row ) {
394 $out[$row->ctd_name] = $row->ctd_count;
400 'checkKeys' => [ $this->wanCache->makeKey(
'tags-usage-statistics' ) ],
401 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
402 'pcTTL' => WANObjectCache::TTL_PROC_LONG
417 $dbProvider = $this->dbProvider;
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 ) {
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 ] )
430 ->fetchFieldValues();
432 return array_unique( $tags );
435 'checkKeys' => [ $this->wanCache->makeKey(
'valid-tags-db' ) ],
436 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
437 'pcTTL' => WANObjectCache::TTL_PROC_LONG
453 $tags = $this->getSoftwareTags(
true );
454 if ( !$this->hookContainer->isRegistered(
'ListDefinedTags' ) ) {
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 ) {
465 return array_unique( $tags );
468 'checkKeys' => [ $this->wanCache->makeKey(
'valid-tags-hook' ) ],
469 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
470 'pcTTL' => WANObjectCache::TTL_PROC_LONG
486 return array_keys( $this->getTagsWithData( $db, $rc_id, $rev_id, $log_id ) );
497 $tags1 = $this->listExplicitlyDefinedTags();
498 $tags2 = $this->listSoftwareDefinedTags();
499 return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
529 public function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id =
null,
533 $tagsToAdd = array_filter(
535 static function ( $value ) {
536 return ( $value ??
'' ) !==
'';
539 $tagsToRemove = array_filter(
540 (array)$tagsToRemove,
541 static function ( $value ) {
542 return ( $value ??
'' ) !==
'';
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!' );
551 $dbw = $this->dbProvider->getPrimaryDatabase();
559 $rc_id = $dbw->newSelectQueryBuilder()
562 ->join(
'recentchanges',
null, [
563 'rc_timestamp = log_timestamp',
566 ->where( [
'log_id' => $log_id ] )
567 ->caller( __METHOD__ )
569 } elseif ( $rev_id ) {
570 $rc_id = $dbw->newSelectQueryBuilder()
573 ->join(
'recentchanges',
null, [
574 'rc_this_oldid = rev_id'
576 ->where( [
'rev_id' => $rev_id ] )
577 ->caller( __METHOD__ )
580 } elseif ( !$log_id && !$rev_id ) {
582 $log_id = $dbw->newSelectQueryBuilder()
583 ->select(
'rc_logid' )
584 ->from(
'recentchanges' )
585 ->where( [
'rc_id' => $rc_id ] )
586 ->caller( __METHOD__ )
588 $rev_id = $dbw->newSelectQueryBuilder()
589 ->select(
'rc_this_oldid' )
590 ->from(
'recentchanges' )
591 ->where( [
'rc_id' => $rc_id ] )
592 ->caller( __METHOD__ )
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__ )
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__ )
612 $prevTags = $this->getTags( $dbw, $rc_id, $rev_id, $log_id );
615 $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
616 $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
619 $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
620 $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
624 if ( $prevTags == $newTags ) {
625 return [ [], [], $prevTags ];
629 if ( count( $tagsToAdd ) ) {
630 $changeTagMapping = [];
631 foreach ( $tagsToAdd as $tag ) {
632 $changeTagMapping[$tag] = $this->changeTagDefStore->acquireId( $tag );
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();
645 foreach ( $tagsToAdd as $tag ) {
650 $tagsRows[] = array_filter(
652 'ct_rc_id' => $rc_id,
653 'ct_log_id' => $log_id,
654 'ct_rev_id' => $rev_id,
656 'ct_tag_id' => $changeTagMapping[$tag] ??
null,
662 $dbw->newInsertQueryBuilder()
663 ->insertInto( self::CHANGE_TAG )
666 ->caller( __METHOD__ )->execute();
670 if ( count( $tagsToRemove ) ) {
672 foreach ( $tagsToRemove as $tag ) {
673 $conds = array_filter(
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 ),
681 $dbw->newDeleteQueryBuilder()
682 ->deleteFrom( self::CHANGE_TAG )
684 ->caller( __METHOD__ )->execute();
685 if ( $dbw->affectedRows() ) {
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();
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();
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 );
707 return [ $tagsToAdd, $tagsToRemove, $prevTags ];
723 public function addTags( $tags, $rc_id =
null, $rev_id =
null,
726 $result = $this->updateTags( $tags,
null, $rc_id, $rev_id, $log_id,
$params, $rc );
727 return (
bool)$result[0];
738 $tags = $this->getSoftwareTags();
739 if ( !$this->hookContainer->isRegistered(
'ChangeTagsListActive' ) ) {
742 $hookRunner = $this->hookRunner;
743 $dbProvider = $this->dbProvider;
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 ) {
756 'checkKeys' => [ $this->wanCache->makeKey(
'active-tags' ) ],
757 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
758 'pcTTL' => WANObjectCache::TTL_PROC_LONG
786 &$join_conds, &$options, $filter_tag =
'',
bool $exclude =
false
788 $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
791 $tables = (array)$tables;
792 $fields = (array)$fields;
793 $conds = (array)$conds;
794 $options = (array)$options;
796 $fields[
'ts_tags'] = $this->makeTagSummarySubquery( $tables );
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';
811 throw new InvalidArgumentException(
'Unable to determine appropriate JOIN condition for tagging.' );
814 if ( !$useTagFilter ) {
818 if ( !is_array( $filter_tag ) ) {
820 $filter_tag = (string)$filter_tag;
823 if ( $filter_tag !== [] && $filter_tag !==
'' ) {
827 foreach ( (array)$filter_tag as $filterTagName ) {
829 $filterTagIds[] = $this->changeTagDefStore->getId( $filterTagName );
835 if ( $filterTagIds !== [] ) {
836 $tables[self::DISPLAY_TABLE_ALIAS] = self::CHANGE_TAG;
837 $join_conds[self::DISPLAY_TABLE_ALIAS] = [
839 [ $join_cond, self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' => $filterTagIds ]
841 $conds[self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id'] =
null;
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;
854 is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
855 !in_array(
'DISTINCT', $options )
857 $options[] =
'DISTINCT';
884 bool $exclude =
false
886 $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
887 $queryBuilder->
field( $this->makeTagSummarySubquery( [ $table ] ),
'ts_tags' );
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';
902 throw new InvalidArgumentException(
'Unable to determine appropriate JOIN condition for tagging.' );
905 if ( !$useTagFilter ) {
909 if ( !is_array( $filter_tag ) ) {
911 $filter_tag = (string)$filter_tag;
914 if ( $filter_tag !== [] && $filter_tag !==
'' ) {
918 foreach ( (array)$filter_tag as $filterTagName ) {
920 $filterTagIds[] = $this->changeTagDefStore->getId( $filterTagName );
926 if ( $filterTagIds !== [] ) {
929 self::DISPLAY_TABLE_ALIAS,
930 [ $join_cond, self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' => $filterTagIds ]
932 $queryBuilder->
where( [ self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' =>
null ] );
937 self::DISPLAY_TABLE_ALIAS,
940 if ( $filterTagIds !== [] ) {
941 $queryBuilder->
where( [ self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' => $filterTagIds ] );
944 $queryBuilder->
where(
'0=1' );
948 is_array( $filter_tag ) && count( $filter_tag ) > 1
array $params
The job parameters.
if(!defined('MW_SETUP_CALLBACK'))
Class for creating new log entries and inserting them into the database.
A class containing constants representing the names of configuration variables.
const UseTagFilter
Name constant for the UseTagFilter setting, for use with Config::get()
const SoftwareTags
Name constant for the SoftwareTags setting, for use with Config::get()
Utility class for creating new RC entries.
Multi-datacenter aware caching interface.