23use InvalidArgumentException;
35use Psr\Log\LoggerInterface;
55 private const CHANGE_TAG =
'change_tag';
60 private const CHANGE_TAG_DEF =
'change_tag_def';
75 private const DEFINED_SOFTWARE_TAGS = [
76 'mw-contentmodelchange',
78 'mw-removed-redirect',
79 'mw-changed-redirect-target',
86 'mw-server-side-upload',
90 private LoggerInterface $logger;
103 LoggerInterface $logger,
108 $this->dbProvider = $dbProvider;
109 $this->logger = $logger;
110 $this->options = $options;
111 $this->changeTagDefStore = $changeTagDefStore;
112 $this->wanCache = $wanCache;
113 $this->hookContainer = $hookContainer;
114 $this->userFactory = $userFactory;
115 $this->hookRunner =
new HookRunner( $hookContainer );
127 if ( !is_array( $coreTags ) ) {
128 $this->logger->warning(
'wgSoftwareTags should be associative array of enabled tags.
129 Please refer to documentation for the list of tags you can enable' );
133 $availableSoftwareTags = !$all ?
134 array_keys( array_filter( $coreTags ) ) :
135 array_keys( $coreTags );
137 return array_intersect(
138 $availableSoftwareTags,
139 self::DEFINED_SOFTWARE_TAGS
157 if ( !$rc_id && !$rev_id && !$log_id ) {
158 throw new InvalidArgumentException(
159 'At least one of: RCID, revision ID, and log ID MUST be ' .
160 'specified when loading tags from a change!' );
163 $conds = array_filter(
165 'ct_rc_id' => $rc_id,
166 'ct_rev_id' => $rev_id,
167 'ct_log_id' => $log_id,
170 $result = $db->newSelectQueryBuilder()
171 ->select( [
'ct_tag_id',
'ct_params' ] )
172 ->from( self::CHANGE_TAG )
174 ->caller( __METHOD__ )
178 foreach ( $result as $row ) {
179 $tagName = $this->changeTagDefStore->getName( (
int)$row->ct_tag_id );
180 $tags[$tagName] = $row->ct_params;
195 $tables = (array)$tables;
198 if ( in_array(
'recentchanges', $tables ) ) {
199 $join_cond =
'ct_rc_id=rc_id';
200 } elseif ( in_array(
'logging', $tables ) ) {
201 $join_cond =
'ct_log_id=log_id';
202 } elseif ( in_array(
'revision', $tables ) ) {
203 $join_cond =
'ct_rev_id=rev_id';
204 } elseif ( in_array(
'archive', $tables ) ) {
205 $join_cond =
'ct_rev_id=ar_rev_id';
207 throw new InvalidArgumentException(
'Unable to determine appropriate JOIN condition for tagging.' );
210 return $this->dbProvider->getReplicaDatabase()
211 ->newSelectQueryBuilder()
212 ->table( self::CHANGE_TAG )
213 ->join( self::CHANGE_TAG_DEF,
null,
'ct_tag_id=ctd_id' )
214 ->field(
'ctd_name' )
215 ->where( $join_cond )
216 ->buildGroupConcatField(
',' );
228 $dbw = $this->dbProvider->getPrimaryDatabase();
229 $dbw->newInsertQueryBuilder()
230 ->insertInto( self::CHANGE_TAG_DEF )
233 'ctd_user_defined' => 1,
236 ->onDuplicateKeyUpdate()
237 ->uniqueIndexFields( [
'ctd_name' ] )
238 ->set( [
'ctd_user_defined' => 1 ] )
239 ->caller( __METHOD__ )->execute();
242 $this->purgeTagCacheAll();
254 $dbw = $this->dbProvider->getPrimaryDatabase();
256 $dbw->newUpdateQueryBuilder()
257 ->update( self::CHANGE_TAG_DEF )
258 ->set( [
'ctd_user_defined' => 0 ] )
259 ->where( [
'ctd_name' => $tag ] )
260 ->caller( __METHOD__ )->execute();
262 $dbw->newDeleteQueryBuilder()
263 ->deleteFrom( self::CHANGE_TAG_DEF )
264 ->where( [
'ctd_name' => $tag,
'ctd_count' => 0 ] )
265 ->caller( __METHOD__ )->execute();
268 $this->purgeTagCacheAll();
286 UserIdentity $user, $tagCount =
null, array $logEntryTags = []
288 $dbw = $this->dbProvider->getPrimaryDatabase();
291 $logEntry->setPerformer( $user );
294 $logEntry->setTarget( Title::newFromText(
'Special:Tags' ) );
295 $logEntry->setComment( $reason );
297 $params = [
'4::tag' => $tag ];
298 if ( $tagCount !==
null ) {
299 $params[
'5:number:count'] = $tagCount;
301 $logEntry->setParameters(
$params );
302 $logEntry->setRelations( [
'Tag' => $tag ] );
303 $logEntry->addTags( $logEntryTags );
305 $logId = $logEntry->insert( $dbw );
306 $logEntry->publish( $logId );
323 $dbw = $this->dbProvider->getPrimaryDatabase();
324 $dbw->startAtomic( __METHOD__ );
327 $tagId = $this->changeTagDefStore->getId( $tag );
330 $this->undefineTag( $tag );
333 $dbw->newDeleteQueryBuilder()
334 ->deleteFrom( self::CHANGE_TAG )
335 ->where( [
'ct_tag_id' => $tagId ] )
336 ->caller( __METHOD__ )->execute();
337 $dbw->newDeleteQueryBuilder()
338 ->deleteFrom( self::CHANGE_TAG_DEF )
339 ->where( [
'ctd_name' => $tag ] )
340 ->caller( __METHOD__ )->execute();
341 $dbw->endAtomic( __METHOD__ );
344 $status = Status::newGood();
345 $this->hookRunner->onChangeTagAfterDelete( $tag, $status );
347 if ( !$status->isOK() ) {
348 $this->logger->debug(
'ChangeTagAfterDelete error condition downgraded to warning' );
349 $status->setOK(
true );
353 $this->purgeTagCacheAll();
364 $this->wanCache->touchCheckKey( $this->wanCache->makeKey(
'active-tags' ) );
365 $this->wanCache->touchCheckKey( $this->wanCache->makeKey(
'valid-tags-db' ) );
366 $this->wanCache->touchCheckKey( $this->wanCache->makeKey(
'valid-tags-hook' ) );
367 $this->wanCache->touchCheckKey( $this->wanCache->makeKey(
'tags-usage-statistics' ) );
369 $this->changeTagDefStore->reloadMap();
380 $dbProvider = $this->dbProvider;
382 return $this->wanCache->getWithSetCallback(
383 $this->wanCache->makeKey(
'tags-usage-statistics' ),
384 WANObjectCache::TTL_MINUTE * 5,
385 static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbProvider ) {
387 $res = $dbr->newSelectQueryBuilder()
388 ->select( [
'ctd_name',
'ctd_count' ] )
389 ->from( self::CHANGE_TAG_DEF )
390 ->orderBy(
'ctd_count', SelectQueryBuilder::SORT_DESC )
395 foreach ( $res as $row ) {
396 $out[$row->ctd_name] = $row->ctd_count;
402 'checkKeys' => [ $this->wanCache->makeKey(
'tags-usage-statistics' ) ],
403 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
404 'pcTTL' => WANObjectCache::TTL_PROC_LONG
419 $dbProvider = $this->dbProvider;
421 return $this->wanCache->getWithSetCallback(
422 $this->wanCache->makeKey(
'valid-tags-db' ),
423 WANObjectCache::TTL_MINUTE * 5,
424 static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbProvider ) {
426 $setOpts += Database::getCacheSetOptions( $dbr );
427 $tags = $dbr->newSelectQueryBuilder()
428 ->select(
'ctd_name' )
429 ->from( self::CHANGE_TAG_DEF )
430 ->where( [
'ctd_user_defined' => 1 ] )
432 ->fetchFieldValues();
434 return array_unique( $tags );
437 'checkKeys' => [ $this->wanCache->makeKey(
'valid-tags-db' ) ],
438 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
439 'pcTTL' => WANObjectCache::TTL_PROC_LONG
455 $tags = $this->getSoftwareTags(
true );
456 if ( !$this->hookContainer->isRegistered(
'ListDefinedTags' ) ) {
459 $hookRunner = $this->hookRunner;
460 $dbProvider = $this->dbProvider;
461 return $this->wanCache->getWithSetCallback(
462 $this->wanCache->makeKey(
'valid-tags-hook' ),
463 WANObjectCache::TTL_MINUTE * 5,
464 static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner, $dbProvider ) {
467 return array_unique( $tags );
470 'checkKeys' => [ $this->wanCache->makeKey(
'valid-tags-hook' ) ],
471 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
472 'pcTTL' => WANObjectCache::TTL_PROC_LONG
488 return array_keys( $this->getTagsWithData( $db, $rc_id, $rev_id, $log_id ) );
499 $tags1 = $this->listExplicitlyDefinedTags();
500 $tags2 = $this->listSoftwareDefinedTags();
501 return array_values( array_unique( array_merge( $tags1, $tags2 ) ) );
531 public function updateTags( $tagsToAdd, $tagsToRemove, &$rc_id =
null,
535 $tagsToAdd = array_filter(
537 static function ( $value ) {
538 return ( $value ??
'' ) !==
'';
541 $tagsToRemove = array_filter(
542 (array)$tagsToRemove,
543 static function ( $value ) {
544 return ( $value ??
'' ) !==
'';
548 if ( !$rc_id && !$rev_id && !$log_id ) {
549 throw new InvalidArgumentException(
'At least one of: RCID, revision ID, and log ID MUST be ' .
550 'specified when adding or removing a tag from a change!' );
553 $dbw = $this->dbProvider->getPrimaryDatabase();
561 $rc_id = $dbw->newSelectQueryBuilder()
564 ->join(
'recentchanges',
null, [
565 'rc_timestamp = log_timestamp',
568 ->where( [
'log_id' => $log_id ] )
569 ->caller( __METHOD__ )
571 } elseif ( $rev_id ) {
572 $rc_id = $dbw->newSelectQueryBuilder()
575 ->join(
'recentchanges',
null, [
576 'rc_this_oldid = rev_id'
578 ->where( [
'rev_id' => $rev_id ] )
579 ->caller( __METHOD__ )
582 } elseif ( !$log_id && !$rev_id ) {
584 $log_id = $dbw->newSelectQueryBuilder()
585 ->select(
'rc_logid' )
586 ->from(
'recentchanges' )
587 ->where( [
'rc_id' => $rc_id ] )
588 ->caller( __METHOD__ )
590 $rev_id = $dbw->newSelectQueryBuilder()
591 ->select(
'rc_this_oldid' )
592 ->from(
'recentchanges' )
593 ->where( [
'rc_id' => $rc_id ] )
594 ->caller( __METHOD__ )
598 if ( $log_id && !$rev_id ) {
599 $rev_id = $dbw->newSelectQueryBuilder()
600 ->select(
'ls_value' )
601 ->from(
'log_search' )
602 ->where( [
'ls_field' =>
'associated_rev_id',
'ls_log_id' => $log_id ] )
603 ->caller( __METHOD__ )
605 } elseif ( !$log_id && $rev_id ) {
606 $log_id = $dbw->newSelectQueryBuilder()
607 ->select(
'ls_log_id' )
608 ->from(
'log_search' )
609 ->where( [
'ls_field' =>
'associated_rev_id',
'ls_value' => (
string)$rev_id ] )
610 ->caller( __METHOD__ )
614 $prevTags = $this->getTags( $dbw, $rc_id, $rev_id, $log_id );
617 $tagsToAdd = array_values( array_diff( $tagsToAdd, $prevTags ) );
618 $newTags = array_unique( array_merge( $prevTags, $tagsToAdd ) );
621 $tagsToRemove = array_values( array_intersect( $tagsToRemove, $newTags ) );
622 $newTags = array_values( array_diff( $newTags, $tagsToRemove ) );
626 if ( $prevTags == $newTags ) {
627 return [ [], [], $prevTags ];
631 if ( count( $tagsToAdd ) ) {
632 $changeTagMapping = [];
633 foreach ( $tagsToAdd as $tag ) {
634 $changeTagMapping[$tag] = $this->changeTagDefStore->acquireId( $tag );
638 $dbw->onTransactionPreCommitOrIdle(
static function () use ( $dbw, $tagsToAdd, $fname ) {
639 $dbw->newUpdateQueryBuilder()
640 ->update( self::CHANGE_TAG_DEF )
641 ->
set( [
'ctd_count' =>
new RawSQLValue(
'ctd_count + 1' ) ] )
642 ->where( [
'ctd_name' => $tagsToAdd ] )
643 ->caller( $fname )->execute();
647 foreach ( $tagsToAdd as $tag ) {
652 $tagsRows[] = array_filter(
654 'ct_rc_id' => $rc_id,
655 'ct_log_id' => $log_id,
656 'ct_rev_id' => $rev_id,
658 'ct_tag_id' => $changeTagMapping[$tag] ??
null,
664 $dbw->newInsertQueryBuilder()
665 ->insertInto( self::CHANGE_TAG )
668 ->caller( __METHOD__ )->execute();
672 if ( count( $tagsToRemove ) ) {
674 foreach ( $tagsToRemove as $tag ) {
675 $conds = array_filter(
677 'ct_rc_id' => $rc_id,
678 'ct_log_id' => $log_id,
679 'ct_rev_id' => $rev_id,
680 'ct_tag_id' => $this->changeTagDefStore->getId( $tag ),
683 $dbw->newDeleteQueryBuilder()
684 ->deleteFrom( self::CHANGE_TAG )
686 ->caller( __METHOD__ )->execute();
687 if ( $dbw->affectedRows() ) {
689 $dbw->onTransactionPreCommitOrIdle(
static function () use ( $dbw, $tag, $fname ) {
690 $dbw->newUpdateQueryBuilder()
691 ->update( self::CHANGE_TAG_DEF )
692 ->
set( [
'ctd_count' =>
new RawSQLValue(
'ctd_count - 1' ) ] )
693 ->where( [
'ctd_name' => $tag ] )
694 ->caller( $fname )->execute();
696 $dbw->newDeleteQueryBuilder()
697 ->deleteFrom( self::CHANGE_TAG_DEF )
698 ->where( [
'ctd_name' => $tag,
'ctd_count' => 0,
'ctd_user_defined' => 0 ] )
699 ->caller( $fname )->execute();
705 $userObj = $user ? $this->userFactory->newFromUserIdentity( $user ) :
null;
706 $this->hookRunner->onChangeTagsAfterUpdateTags(
707 $tagsToAdd, $tagsToRemove, $prevTags, $rc_id, $rev_id, $log_id,
$params, $rc, $userObj );
709 return [ $tagsToAdd, $tagsToRemove, $prevTags ];
725 public function addTags( $tags, $rc_id =
null, $rev_id =
null,
728 $result = $this->updateTags( $tags,
null, $rc_id, $rev_id, $log_id,
$params, $rc );
729 return (
bool)$result[0];
740 $tags = $this->getSoftwareTags();
741 if ( !$this->hookContainer->isRegistered(
'ChangeTagsListActive' ) ) {
744 $hookRunner = $this->hookRunner;
745 $dbProvider = $this->dbProvider;
747 return $this->wanCache->getWithSetCallback(
748 $this->wanCache->makeKey(
'active-tags' ),
749 WANObjectCache::TTL_MINUTE * 5,
750 static function ( $oldValue, &$ttl, array &$setOpts ) use ( $tags, $hookRunner, $dbProvider ) {
758 'checkKeys' => [ $this->wanCache->makeKey(
'active-tags' ) ],
759 'lockTSE' => WANObjectCache::TTL_MINUTE * 5,
760 'pcTTL' => WANObjectCache::TTL_PROC_LONG
788 &$join_conds, &$options, $filter_tag =
'',
bool $exclude =
false
790 $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
793 $tables = (array)$tables;
794 $fields = (array)$fields;
795 $conds = (array)$conds;
796 $options = (array)$options;
798 $fields[
'ts_tags'] = $this->makeTagSummarySubquery( $tables );
804 if ( in_array(
'recentchanges', $tables ) ) {
805 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_rc_id=rc_id';
806 } elseif ( in_array(
'logging', $tables ) ) {
807 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_log_id=log_id';
808 } elseif ( in_array(
'revision', $tables ) ) {
809 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_rev_id=rev_id';
810 } elseif ( in_array(
'archive', $tables ) ) {
811 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_rev_id=ar_rev_id';
813 throw new InvalidArgumentException(
'Unable to determine appropriate JOIN condition for tagging.' );
816 if ( !$useTagFilter ) {
820 if ( !is_array( $filter_tag ) ) {
822 $filter_tag = (string)$filter_tag;
825 if ( $filter_tag !== [] && $filter_tag !==
'' ) {
829 foreach ( (array)$filter_tag as $filterTagName ) {
831 $filterTagIds[] = $this->changeTagDefStore->getId( $filterTagName );
837 if ( $filterTagIds !== [] ) {
838 $tables[self::DISPLAY_TABLE_ALIAS] = self::CHANGE_TAG;
839 $join_conds[self::DISPLAY_TABLE_ALIAS] = [
841 [ $join_cond, self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' => $filterTagIds ]
843 $conds[self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id'] =
null;
846 $tables[self::DISPLAY_TABLE_ALIAS] = self::CHANGE_TAG;
847 $join_conds[self::DISPLAY_TABLE_ALIAS] = [
'JOIN', $join_cond ];
848 if ( $filterTagIds !== [] ) {
849 $conds[self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id'] = $filterTagIds;
856 is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
857 !in_array(
'DISTINCT', $options )
859 $options[] =
'DISTINCT';
886 bool $exclude =
false
888 $useTagFilter = $this->options->get( MainConfigNames::UseTagFilter );
889 $queryBuilder->
field( $this->makeTagSummarySubquery( [ $table ] ),
'ts_tags' );
895 if ( $table ===
'recentchanges' ) {
896 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_rc_id=rc_id';
897 } elseif ( $table ===
'logging' ) {
898 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_log_id=log_id';
899 } elseif ( $table ===
'revision' ) {
900 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_rev_id=rev_id';
901 } elseif ( $table ===
'archive' ) {
902 $join_cond = self::DISPLAY_TABLE_ALIAS .
'.ct_rev_id=ar_rev_id';
904 throw new InvalidArgumentException(
'Unable to determine appropriate JOIN condition for tagging.' );
907 if ( !$useTagFilter ) {
911 if ( !is_array( $filter_tag ) ) {
913 $filter_tag = (string)$filter_tag;
916 if ( $filter_tag !== [] && $filter_tag !==
'' ) {
920 foreach ( (array)$filter_tag as $filterTagName ) {
922 $filterTagIds[] = $this->changeTagDefStore->getId( $filterTagName );
928 if ( $filterTagIds !== [] ) {
931 self::DISPLAY_TABLE_ALIAS,
932 [ $join_cond, self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' => $filterTagIds ]
934 $queryBuilder->
where( [ self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' =>
null ] );
939 self::DISPLAY_TABLE_ALIAS,
942 if ( $filterTagIds !== [] ) {
943 $queryBuilder->
where( [ self::DISPLAY_TABLE_ALIAS .
'.ct_tag_id' => $filterTagIds ] );
946 $queryBuilder->
where(
'0=1' );
950 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 and reading rows in the recentchanges table.