Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.45% covered (warning)
72.45%
426 / 588
64.29% covered (warning)
64.29%
63 / 98
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListQuery
72.45% covered (warning)
72.45%
426 / 588
64.29% covered (warning)
64.29%
63 / 98
971.98
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
1
 applyAction
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 requireNamespaces
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 excludeNamespaces
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyArrayAction
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 requireSubpageOf
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 requireTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 requireWatched
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requireWatchlistLabelIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 excludeWatchlistLabelIds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requireLink
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 requireSources
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requireUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 excludeUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 requirePatrolled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 requireChangeTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 excludeChangeTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requireLatest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 excludeOldRevisions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 requireSlotChanged
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
2.01
 excludeDeletedLogAction
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 allowDeletedLogAction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 excludeDeletedUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 denseRcSizeThreshold
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 audience
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 highlight
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 minTimestamp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 startAt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 endAt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 orderBy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 limit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 adjustDensity
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 joinOrderHint
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 forceEmptySet
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isEmptySet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maxExecutionTime
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 enablePartitioning
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 forcePartitioning
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getWatchlistJoinModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWatchlistExpiryJoinModule
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSlotsJoinModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWatchedFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSeenFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getChangeTagsFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWatchlistLabelFilter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRedirectFilter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionTypeFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPatrolledFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitleCondition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSubpageOfCondition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 watchlistUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rcUserFields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addChangeTagSummaryField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addWatchlistLabelSummaryField
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 recentChangeFields
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 watchlistFields
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 sha1Fields
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 applySha1Fields
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
2
 addRedirectField
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 commentFields
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 maybeAddWatchlistExpiryField
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 legacyMutator
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 sqbMutator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fetchResult
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
7
 prepare
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 prepareEmulatedUnion
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 newResult
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareAudienceCondition
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
11
 createQueryBuilder
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 applyOptions
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 applyTimestampFilter
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 applyStartOrEnd
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getUniqueFields
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 applyMutators
22.22% covered (danger)
22.22%
4 / 18
0.00% covered (danger)
0.00%
0 / 1
16.76
 applyLinkTarget
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 shouldUseVirtualDomains
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyLinksToCondition
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
6.14
 applyLinksToFilter
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 applyLinksFromCondition
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 applyLinksFromFilter
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 maybeEmulateUnion
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 emulateUnion
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 sortAndTruncate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 shouldDoPartitioning
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 estimateSize
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 doPartitionUnion
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
5.50
 doPartitionQuery
95.83% covered (success)
95.83%
46 / 48
0.00% covered (danger)
0.00%
0 / 1
6
 getHighlightsFromRow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getFilter
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 where
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 caller
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 joinForFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 joinForConds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getJoin
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 distinct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 registerFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\RecentChanges\ChangesListQuery;
4
5use InvalidArgumentException;
6use LogicException;
7use MediaWiki\ChangeTags\ChangeTagsStore;
8use MediaWiki\Config\ServiceOptions;
9use MediaWiki\Deferred\LinksUpdate\LinksTable;
10use MediaWiki\Linker\LinkTarget;
11use MediaWiki\Linker\LinkTargetLookup;
12use MediaWiki\Logging\LogPage;
13use MediaWiki\MainConfigNames;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Page\PageIdentity;
16use MediaWiki\Page\PageReference;
17use MediaWiki\Permissions\Authority;
18use MediaWiki\RecentChanges\RecentChange;
19use MediaWiki\RecentChanges\RecentChangeLookup;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\Storage\NameTableAccessException;
22use MediaWiki\Storage\NameTableStore;
23use MediaWiki\Title\TitleValue;
24use MediaWiki\User\TempUser\TempUserConfig;
25use MediaWiki\User\UserFactory;
26use MediaWiki\User\UserIdentity;
27use MediaWiki\Watchlist\WatchedItemStoreInterface;
28use Psr\Log\LoggerInterface;
29use stdClass;
30use Wikimedia\Rdbms\IExpression;
31use Wikimedia\Rdbms\IReadableDatabase;
32use Wikimedia\Rdbms\IResultWrapper;
33use Wikimedia\Rdbms\RawSQLExpression;
34use Wikimedia\Rdbms\SelectQueryBuilder;
35use Wikimedia\Stats\StatsFactory;
36use Wikimedia\Timestamp\ConvertibleTimestamp;
37use Wikimedia\Timestamp\TimestampFormat as TS;
38use function array_key_exists;
39
40/**
41 * Build and execute a query on the recentchanges table with optional joins and conditions.
42 *
43 * Obtain instance via `MediaWikiServices::getChangesListQueryFactory()->newQuery()`
44 *
45 * @since 1.45
46 * @ingroup RecentChanges
47 */
48class ChangesListQuery implements QueryBackend, JoinDependencyProvider {
49    public const CONSTRUCTOR_OPTIONS = [
50        MainConfigNames::WatchlistExpiry,
51        MainConfigNames::MiserMode,
52        MainConfigNames::RCMaxAge,
53        MainConfigNames::EnableChangesListQueryPartitioning,
54        MainConfigNames::VirtualDomainsMapping,
55        ...ExperienceCondition::CONSTRUCTOR_OPTIONS
56    ];
57
58    public const LINKS_FROM = 'from';
59    public const LINKS_TO = 'to';
60
61    private const LINK_TABLE_PREFIXES = [
62        'pagelinks' => 'pl',
63        'templatelinks' => 'tl',
64        'categorylinks' => 'cl',
65        'imagelinks' => 'il'
66    ];
67
68    /** Minimum number of estimated rows before timestamp partitioning is considered */
69    public const PARTITION_THRESHOLD = 10000;
70
71    public const SORT_TIMESTAMP_DESC = 'timestamp-desc';
72    public const SORT_TIMESTAMP_ASC = 'timestamp-asc';
73
74    private int|float $rcMaxAge;
75    private bool $enablePartitioning;
76    private bool $forcePartitioning = false;
77    private array $virtualDomainsMapping;
78
79    private array $densityTunables = [
80        self::DENSITY_LINKS => 0.1,
81        self::DENSITY_WATCHLIST => 0.1,
82        self::DENSITY_USER => 0.1,
83        self::DENSITY_CHANGE_TAG_THRESHOLD => 0.5,
84    ];
85
86    /** @var ChangesListCondition[] */
87    private $filterModules;
88
89    /** @var ChangesListJoinModule[] */
90    private $joinModules;
91
92    /** @var ChangesListHighlight[][] */
93    private $highlights = [];
94
95    /** @var string[] */
96    private $fields = [];
97    /** @var IExpression[] */
98    private $conds = [];
99
100    /** @var string|null */
101    private $linkDirection = null;
102    /** @var string[] */
103    private $linkTables = [];
104    /** @var PageIdentity|null */
105    private $linkTarget = null;
106
107    /** @var bool Whether the query was prepared for an emulated union */
108    private $preparedEmulatedUnion = false;
109
110    /**
111     * Internal functions to call during the prepare stage.
112     * @var array<string,callable>
113     */
114    private $prepareCallbacks = [];
115
116    /** @var string|null The minimum or earliest timestamp */
117    private $minTimestamp = null;
118
119    /** @var string|null The timestamp to start at */
120    private $startTimestamp = null;
121    /** @var int|null The ID to start at */
122    private $startId = null;
123    /** @var string|null The timestamp to end at */
124    private $endTimestamp = null;
125    /** @var int|null The ID to end at */
126    private $endId = null;
127    /** @var string The sort order */
128    private $sort = self::SORT_TIMESTAMP_DESC;
129
130    /** @var int|null The maximum number of rows to return */
131    private ?int $limit = null;
132
133    /** @var float|int A naïve estimate of the fraction of rows matched by the conditions */
134    private $density = 1;
135
136    /**
137     * @var string Whether recentchanges or some other table will likely be
138     *   first in the join.
139     */
140    private $joinOrderHint = self::JOIN_ORDER_RECENTCHANGES;
141
142    /** @var Authority|null The authority to use for deleted bitfield checks */
143    private ?Authority $audience = null;
144
145    /** @var bool Whether to exclude log entries with deleted actions */
146    private $excludeDeletedAction = false;
147    /** @var bool Whether to exclude rows with deleted users */
148    private $excludeDeletedUser = false;
149
150    /** @var float|int|null */
151    private $maxExecutionTime = null;
152
153    /** @var bool If true, return no results */
154    private $forceEmptySet = false;
155
156    /** @var bool If true, add DISTINCT and GROUP BY */
157    private $distinct = false;
158
159    /** @var string|null The caller to pass down to the DBMS */
160    private ?string $caller = null;
161
162    /** @var callable[] */
163    private $legacyMutators = [];
164    /** @var callable[] */
165    private $sqbMutators = [];
166
167    /**
168     * @internal For use by ChangesListQueryFactory
169     */
170    public function __construct(
171        private ServiceOptions $config,
172        private RecentChangeLookup $recentChangeLookup,
173        private WatchedItemStoreInterface $watchedItemStore,
174        private TempUserConfig $tempUserConfig,
175        private UserFactory $userFactory,
176        private LinkTargetLookup $linkTargetLookup,
177        private ChangeTagsStore $changeTagsStore,
178        private StatsFactory $statsFactory,
179        private NameTableStore $slotRoleStore,
180        private LoggerInterface $logger,
181        private IReadableDatabase $db,
182        private TableStatsProvider $rcStats,
183    ) {
184        $this->filterModules = [
185            'experience' => new ExperienceCondition(
186                $config,
187                $this->tempUserConfig,
188                $this->userFactory,
189            ),
190            'user' => new UserCondition(),
191            'named' => new NamedCondition( $this->tempUserConfig ),
192            'bot' => new BooleanFieldCondition( 'rc_bot' ),
193            'minor' => new BooleanFieldCondition( 'rc_minor' ),
194            'redirect' => new BooleanJoinFieldCondition( 'page_is_redirect', 'page' ),
195            'revisionType' => new RevisionTypeCondition(),
196            'source' => new EnumFieldCondition(
197                'rc_source',
198                $this->recentChangeLookup->getAllSources()
199            ),
200            'logType' => new FieldEqualityCondition( 'rc_log_type', true ),
201            'patrolled' => new EnumFieldCondition(
202                'rc_patrolled',
203                [
204                    RecentChange::PRC_UNPATROLLED,
205                    RecentChange::PRC_PATROLLED,
206                    RecentChange::PRC_AUTOPATROLLED,
207                ]
208            ),
209            'watched' => new WatchedCondition(
210                (bool)$config->get( MainConfigNames::WatchlistExpiry )
211            ),
212            'seen' => new SeenCondition(
213                $this->watchedItemStore
214            ),
215            'watchlistLabel' => new WatchlistLabelCondition(),
216            'namespace' => new FieldEqualityCondition( 'rc_namespace' ),
217            'title' => new TitleCondition(),
218            'subpageof' => new SubpageOfCondition(),
219        ];
220
221        // ChangeTagsCondition consumes the density heuristic so it has to
222        // be prepared after the other modules. Putting it late in the list
223        // serves that purpose.
224        $this->filterModules['changeTags'] = new ChangeTagsCondition(
225            $this->changeTagsStore,
226            $this->rcStats,
227            $this->logger,
228            (bool)$config->get( MainConfigNames::MiserMode ),
229        );
230
231        $this->joinModules = [
232            'actor' => new BasicJoin( 'actor', 'recentchanges_actor', 'actor_id=rc_actor' ),
233            'change_tag' => new BasicJoin(
234                'change_tag',
235                'changetagdisplay',
236                'changetagdisplay.ct_rc_id=rc_id'
237            ),
238            'comment' => new BasicJoin( 'comment', 'recentchanges_comment', 'comment_id=rc_comment_id' ),
239            'page' => new BasicJoin( 'page', '', 'page_id=rc_cur_id' ),
240            'revision' => new BasicJoin( 'revision', '', 'rev_id=rc_this_oldid' ),
241            'slots' => new SlotsJoin(),
242            'user' => new BasicJoin( 'user', '', 'user_id=actor_user', 'actor' ),
243            'watchlist' => new WatchlistJoin(),
244            'watchlist_expiry' => new BasicJoin( 'watchlist_expiry', '', 'we_item=wl_id', 'watchlist' ),
245            'watchlist_label_member' => new BasicJoin(
246                'watchlist_label_member',
247                '',
248                'wlm_item=wl_id',
249                [ 'watchlist' ]
250            ),
251        ];
252
253        $this->rcMaxAge = (int)$config->get( MainConfigNames::RCMaxAge );
254        $this->enablePartitioning = (bool)$config->get( MainConfigNames::EnableChangesListQueryPartitioning );
255        $this->virtualDomainsMapping = $config->get( MainConfigNames::VirtualDomainsMapping );
256    }
257
258    /**
259     * Apply an arbitrary action. This is used to implement filter definitions
260     * in ChangesListSpecialPage. Other callers should add/use a separate
261     * mutator method. The details of the module names and values are internal
262     * and unstable.
263     *
264     * Note regarding implicit unions:
265     *
266     * Conventionally, if you require two things of the same kind, like two
267     * namespaces, you will get results matching either condition. But if you
268     * require two different kinds of condition, like a namespace and a minor
269     * edit, you will only get results matching both conditions. In other words,
270     * filter modules implement an implicit union of required values.
271     *
272     * However, exclusions intersect with requirements of the same kind, so if
273     * you require minor edits, and also exclude minor edits, you get no
274     * results.
275     *
276     * This convention is flexible, consistent, and works well with the UI.
277     *
278     * @internal
279     * @param string $verb May be "require" or "exclude"
280     * @param string $moduleName The name of the module, the thing to be required
281     * @param mixed $value An optional value to pass to the module
282     * @return $this
283     */
284    public function applyAction( string $verb, string $moduleName, $value = null ) {
285        $module = $this->getFilter( $moduleName );
286        switch ( $verb ) {
287            case 'require':
288                $module->require( $value );
289                break;
290            case 'exclude':
291                $module->exclude( $value );
292                break;
293            default:
294                throw new InvalidArgumentException(
295                    "Unknown filter action verb: \"$verb\"" );
296        }
297        return $this;
298    }
299
300    /**
301     * Require namespaces by ID
302     *
303     * @param int[] $namespaces
304     * @return $this
305     */
306    public function requireNamespaces( array $namespaces ) {
307        return $this->applyArrayAction( 'require', 'namespace', $namespaces );
308    }
309
310    /**
311     * Exclude namespaces by ID
312     *
313     * @param int[] $namespaces
314     * @return $this
315     */
316    public function excludeNamespaces( array $namespaces ) {
317        return $this->applyArrayAction( 'exclude', 'namespace', $namespaces );
318    }
319
320    /**
321     * Apply an action multiple times, once for each of the values in the array
322     *
323     * @param string $verb
324     * @param string $moduleName
325     * @param array $values
326     * @return $this
327     */
328    private function applyArrayAction( string $verb, string $moduleName, array $values ) {
329        foreach ( $values as $value ) {
330            $this->applyAction( $verb, $moduleName, $value );
331        }
332        return $this;
333    }
334
335    /**
336     * Require that changed titles are subpages of a given page.
337     *
338     * @param LinkTarget|PageReference $page
339     * @return $this
340     */
341    public function requireSubpageOf( LinkTarget|PageReference $page ) {
342        $this->getSubpageOfCondition()->require( $page );
343        return $this;
344    }
345
346    /**
347     * Return only changes to a given page.
348     *
349     * @param LinkTarget|PageReference $title
350     * @return $this
351     */
352    public function requireTitle( LinkTarget|PageReference $title ) {
353        $this->getTitleCondition()->require( $title );
354        return $this;
355    }
356
357    /**
358     * Require that the changed page is watched by the watchlist user specified
359     * in a call to watchlistUser().
360     *
361     * @param string[] $watchTypes
362     * @return $this
363     */
364    public function requireWatched( $watchTypes = [ 'watchedold', 'watchednew' ] ) {
365        return $this->applyArrayAction( 'require', 'watched', $watchTypes );
366    }
367
368    /**
369     * Require that the changed page is watched with one of the specified
370     * watchlist label IDs.
371     *
372     * @since 1.46
373     * @param int[] $labelIds
374     * @return $this
375     */
376    public function requireWatchlistLabelIds( array $labelIds ) {
377        return $this->applyArrayAction( 'require', 'watchlistLabel', $labelIds );
378    }
379
380    /**
381     * Require that the changed page is not watched with one of the specified
382     * watchlist label IDs.
383     *
384     * @since 1.46
385     * @param int[] $labelIds
386     * @return $this
387     */
388    public function excludeWatchlistLabelIds( array $labelIds ) {
389        return $this->applyArrayAction( 'exclude', 'watchlistLabel', $labelIds );
390    }
391
392    /**
393     * Require that the changed page links from or to the specified page, via
394     * the specified links tables.
395     *
396     * @param string $direction Either self::LINKS_FROM or self::LINKS_TO
397     * @param string[] $tables
398     * @param PageIdentity $page
399     * @return $this
400     */
401    public function requireLink( string $direction, array $tables, PageIdentity $page ) {
402        if ( count( $tables ) == 0 ) {
403            throw new InvalidArgumentException( 'Need at least one link table' );
404        }
405        $unknownTables = array_diff( $tables, array_keys( self::LINK_TABLE_PREFIXES ) );
406        if ( $unknownTables ) {
407            throw new InvalidArgumentException( 'Unknown link table(s): ' .
408                implode( ', ', $unknownTables ) );
409        }
410
411        $this->linkDirection = $direction;
412        $this->linkTables = $tables;
413        $this->linkTarget = $page;
414        return $this;
415    }
416
417    /**
418     * Require that the changes come from the specified sources, e.g. RecentChange::SRC_EDIT
419     *
420     * @param array $sources
421     * @return $this
422     */
423    public function requireSources( array $sources ): self {
424        return $this->applyArrayAction( 'require', 'source', $sources );
425    }
426
427    /**
428     * Require changes by a specific user.
429     *
430     * @param UserIdentity $user
431     * @return $this
432     */
433    public function requireUser( UserIdentity $user ): self {
434        $this->getUserFilter()->require( $user );
435        return $this;
436    }
437
438    /**
439     * Exclude changes by a specific user.
440     *
441     * @param UserIdentity $user
442     * @return $this
443     */
444    public function excludeUser( UserIdentity $user ): self {
445        $this->getUserFilter()->exclude( $user );
446        return $this;
447    }
448
449    /**
450     * Require a patrolled status.
451     *
452     * @param int $value One of the RecentChange::PRC_xxx constants
453     * @return $this
454     */
455    public function requirePatrolled( $value ): self {
456        $this->getPatrolledFilter()->require( $value );
457        return $this;
458    }
459
460    /**
461     * Require that the change has one of the specified change tags.
462     *
463     * @param string[] $tagNames
464     * @return $this
465     */
466    public function requireChangeTags( $tagNames ): self {
467        return $this->applyArrayAction( 'require', 'changeTags', $tagNames );
468    }
469
470    /**
471     * Exclude changes matching any of the specified change tags.
472     *
473     * @param string[] $tagNames
474     * @return $this
475     */
476    public function excludeChangeTags( $tagNames ): self {
477        return $this->applyArrayAction( 'exclude', 'changeTags', $tagNames );
478    }
479
480    /**
481     * Require that the change is the latest change to the page.
482     * Changes that do not link to a page will not be shown.
483     *
484     * @return $this
485     */
486    public function requireLatest(): self {
487        $this->getRevisionTypeFilter()->require( 'latest' );
488        return $this;
489    }
490
491    /**
492     * Exclude old revisions. Latest revisions and changes that do not
493     * link to a revision, such as log entries, are allowed by this filter.
494     *
495     * @return self
496     */
497    public function excludeOldRevisions(): self {
498        $this->getRevisionTypeFilter()->exclude( 'old' );
499        return $this;
500    }
501
502    /**
503     * Require that a specified slot role was modified
504     *
505     * @param string $role
506     * @return $this
507     */
508    public function requireSlotChanged( string $role ): self {
509        try {
510            $roleId = $this->slotRoleStore->getId( $role );
511        } catch ( NameTableAccessException ) {
512            // No revisions changed this role yet
513            $this->forceEmptySet();
514            return $this;
515        }
516
517        $this->prepareCallbacks['slotChanged'] = function () use ( $roleId ) {
518            $slotsJoin = $this->getSlotsJoinModule();
519            $slotsJoin->setRoleId( $roleId );
520            $slotsJoin->forConds( $this )
521                ->left();
522
523            $slotsJoin->parentAlias()
524                ->forConds()
525                ->left();
526
527            // Detecting whether the slot has been touched as follows:
528            // 1. if slot_origin=slot_revision_id then the slot has been newly created or edited
529            // with this revision
530            // 2. otherwise if the content of a slot is different to the content of its parent slot,
531            // then the content of the slot has been changed in this revision
532            // (probably by a revert)
533            $this->where( $this->db->orExpr( [
534                new RawSQLExpression( 'slot.slot_origin = slot.slot_revision_id' ),
535                new RawSQLExpression( 'slot.slot_content_id != parent_slot.slot_content_id' ),
536                $this->db->expr( 'slot.slot_content_id', '=', null )->and( 'parent_slot.slot_content_id', '!=', null ),
537                $this->db->expr( 'slot.slot_content_id', '!=', null )->and( 'parent_slot.slot_content_id', '=', null ),
538            ] ) );
539        };
540        return $this;
541    }
542
543    /**
544     * Exclude rows relating to log entries that have the DELETED_ACTION bit
545     * set, unless the configured audience has permission to view such rows.
546     *
547     * @return $this
548     */
549    public function excludeDeletedLogAction(): self {
550        $this->excludeDeletedAction = true;
551        return $this;
552    }
553
554    /**
555     * Override a previous call to excludeDeletedLogAction(), allowing deleted
556     * log rows to be shown.
557     *
558     * @return $this
559     */
560    public function allowDeletedLogAction(): self {
561        $this->excludeDeletedAction = false;
562        return $this;
563    }
564
565    /**
566     * Exclude rows with the DELETED_USER bit set, unless the configured
567     * audience has permission to view such rows.
568     *
569     * @return $this
570     */
571    public function excludeDeletedUser(): self {
572        $this->excludeDeletedUser = true;
573        return $this;
574    }
575
576    /**
577     * Set the minimum size of the recentchanges table at which change tag
578     * queries will be conditionally modified based on estimated density.
579     *
580     * @param float|int $threshold
581     * @return self
582     */
583    public function denseRcSizeThreshold( $threshold ): self {
584        $this->getChangeTagsFilter()->setDenseRcSizeThreshold( $threshold );
585        return $this;
586    }
587
588    /**
589     * Set the Authority used for rc_deleted filters.
590     *
591     * @param Authority|null $authority
592     * @return $this
593     */
594    public function audience( ?Authority $authority ) {
595        $this->audience = $authority;
596        return $this;
597    }
598
599    /**
600     * Add a highlight to the query. A highlight is a client-side evaluation of
601     * a filter condition, given a caller-defined name. Results are available
602     * via ChangesListResult::getHighlightsFromRow().
603     *
604     * If this is called more than once with the same name, the name will be
605     * available in the result if any of the actions matched.
606     *
607     * This has no effect if it is called after the query is executed.
608     *
609     * @internal For ChangesListSpecialPage. The module names and values are
610     *   internal and are subject to change.
611     *
612     * @param string $name The arbitrary highlight name
613     * @param string $verb The filter action verb, "require" or "exclude"
614     * @param string $moduleName The module name, e.g. "bot"
615     * @param mixed $value An optional value to pass to the filter module
616     * @return $this
617     */
618    public function highlight( string $name, string $verb, string $moduleName, $value = null ) {
619        $module = $this->getFilter( $moduleName );
620        // Validate now while the responsible caller is in the stack
621        $value = $module->validateValue( $value );
622        $module->capture();
623        $sense = match ( $verb ) {
624            'require' => true,
625            'exclude' => false,
626        };
627        $this->highlights[$name][] = new ChangesListHighlight( $sense, $moduleName, $value );
628        return $this;
629    }
630
631    /**
632     * Set the minimum (earliest) rc_timestamp value.
633     *
634     * @param string $timestamp MW 14-char timestamp
635     * @return $this
636     */
637    public function minTimestamp( $timestamp ) {
638        $this->minTimestamp = $timestamp;
639        return $this;
640    }
641
642    /**
643     * Set the timestamp and ID for the start of the query results. If the sort
644     * order is descending (the default) this is the maximum timestamp and ID.
645     * If the sort order is ascending, this is the minimum timestamp and ID. The
646     * ID, if specified, is used to break ties between results with equal
647     * timestamps.
648     *
649     * @param string $timestamp
650     * @param int|null $id
651     * @return $this
652     */
653    public function startAt( string $timestamp, ?int $id = null ): self {
654        $this->startTimestamp = $timestamp;
655        $this->startId = $id;
656        return $this;
657    }
658
659    /**
660     * Set the timestamp and ID for the end of the query results. If the sort
661     * order is descending (the default) this is the minimum timestamp and ID.
662     * If the sort order is ascending, this is the maximum timestamp and ID. The
663     * ID, if specified, is used to break ties between results with equal
664     * timestamps.
665     *
666     * @param string $timestamp
667     * @param int|null $id
668     * @return $this
669     */
670    public function endAt( string $timestamp, ?int $id = null ): self {
671        $this->endTimestamp = $timestamp;
672        $this->endId = $id;
673        return $this;
674    }
675
676    /**
677     * Set the sort order. Must be one of the SORT_xxx constants.
678     *
679     * @param string $sort
680     * @return $this
681     */
682    public function orderBy( $sort ) {
683        $this->sort = $sort;
684        return $this;
685    }
686
687    /**
688     * Set the maximum number of rows to return.
689     *
690     * @param int $limit
691     * @return $this
692     */
693    public function limit( int $limit ) {
694        $this->limit = $limit;
695        $this->getChangeTagsFilter()->setLimit( $limit );
696        return $this;
697    }
698
699    /** @inheritDoc */
700    public function adjustDensity( $density ): self {
701        if ( is_string( $density ) ) {
702            if ( isset( $this->densityTunables[$density] ) ) {
703                $density = $this->densityTunables[$density];
704            } else {
705                throw new \InvalidArgumentException( "Unknown density \"$density\"" );
706            }
707        }
708        $this->density *= $density;
709        $this->getChangeTagsFilter()->setDensityThresholdReached(
710            $this->density >= $this->densityTunables[self::DENSITY_CHANGE_TAG_THRESHOLD]
711        );
712        return $this;
713    }
714
715    /** @inheritDoc */
716    public function joinOrderHint( $order ): self {
717        $this->joinOrderHint = $order;
718        return $this;
719    }
720
721    /**
722     * Set a flag forcing the query to return no rows when it is executed. Like
723     * adding a 0=1 condition.
724     *
725     * @return $this
726     */
727    public function forceEmptySet(): self {
728        $this->forceEmptySet = true;
729        return $this;
730    }
731
732    /**
733     * Check whether forceEmptySet() has been called. Note that the query may
734     * still return no rows even if this is false.
735     *
736     * @return bool
737     */
738    public function isEmptySet(): bool {
739        return $this->forceEmptySet;
740    }
741
742    /**
743     * Set the maximum query execution time in seconds, or null to disable the
744     * time limit.
745     *
746     * @param float|int|null $time
747     * @return $this
748     */
749    public function maxExecutionTime( float|int|null $time ) {
750        $this->maxExecutionTime = $time;
751        return $this;
752    }
753
754    /**
755     * Enable query partitioning by timestamp, overriding the config
756     *
757     * @return $this
758     */
759    public function enablePartitioning(): self {
760        $this->enablePartitioning = true;
761        return $this;
762    }
763
764    /**
765     * Force partitioning, for testing
766     *
767     * @return $this
768     */
769    public function forcePartitioning(): self {
770        $this->forcePartitioning = true;
771        return $this;
772    }
773
774    private function getWatchlistJoinModule(): WatchlistJoin {
775        return $this->joinModules['watchlist'];
776    }
777
778    private function getWatchlistExpiryJoinModule(): BasicJoin {
779        return $this->joinModules['watchlist_expiry'];
780    }
781
782    private function getSlotsJoinModule(): SlotsJoin {
783        return $this->joinModules['slots'];
784    }
785
786    private function getUserFilter(): UserCondition {
787        return $this->filterModules['user'];
788    }
789
790    private function getWatchedFilter(): WatchedCondition {
791        return $this->filterModules['watched'];
792    }
793
794    private function getSeenFilter(): SeenCondition {
795        return $this->filterModules['seen'];
796    }
797
798    private function getChangeTagsFilter(): ChangeTagsCondition {
799        return $this->filterModules['changeTags'];
800    }
801
802    private function getWatchlistLabelFilter(): WatchlistLabelCondition {
803        return $this->filterModules['watchlistLabel'];
804    }
805
806    private function getRedirectFilter(): BooleanJoinFieldCondition {
807        return $this->filterModules['redirect'];
808    }
809
810    private function getRevisionTypeFilter(): RevisionTypeCondition {
811        return $this->filterModules['revisionType'];
812    }
813
814    private function getPatrolledFilter(): EnumFieldCondition {
815        return $this->filterModules['patrolled'];
816    }
817
818    private function getTitleCondition(): TitleCondition {
819        return $this->filterModules['title'];
820    }
821
822    private function getSubpageOfCondition(): SubpageOfCondition {
823        return $this->filterModules['subpageof'];
824    }
825
826    /**
827     * Set the user to be used for watchlist joins.
828     *
829     * @param UserIdentity $user
830     * @return $this
831     */
832    public function watchlistUser( UserIdentity $user ) {
833        $this->getWatchlistJoinModule()->setUser( $user );
834        $this->getSeenFilter()->setUser( $user );
835        $this->getWatchedFilter()->setUser( $user );
836        return $this;
837    }
838
839    /**
840     * Add fields to the query.
841     *
842     * @param string|string[] $fields
843     * @param-taint $fields exec_sql
844     * @return $this
845     */
846    public function fields( $fields ): self {
847        $fields = is_array( $fields ) ? $fields : [ $fields ];
848        $this->fields = array_merge( $this->fields, $fields );
849        return $this;
850    }
851
852    /** @inheritDoc */
853    public function rcUserFields(): QueryBackend {
854        $this->getJoin( 'actor' )->forFields( $this )->straight();
855        $this->fields['rc_user'] = 'recentchanges_actor.actor_user';
856        $this->fields['rc_user_text'] = 'recentchanges_actor.actor_name';
857        return $this;
858    }
859
860    /**
861     * Add the change tag summary field ts_tags
862     *
863     * @return $this
864     */
865    public function addChangeTagSummaryField(): self {
866        $this->getChangeTagsFilter()->capture();
867        return $this;
868    }
869
870    /**
871     * Add the labels summary field wlm_label_summary
872     *
873     * @return $this
874     */
875    public function addWatchlistLabelSummaryField(): self {
876        $this->getWatchlistLabelFilter()->capture();
877        return $this;
878    }
879
880    /**
881     * Add fields to the query sufficient for the subsequent construction of
882     * RecentChange objects from the returned rows.
883     *
884     * @return $this
885     */
886    public function recentChangeFields() {
887        $this->prepareCallbacks['recentChangeFields'] = function () {
888            $this->fields( RecentChange::getQueryInfo()['fields'] );
889            $this->joinForFields( 'actor' )->straight();
890            $this->joinForFields( 'comment' )->straight();
891        };
892        return $this;
893    }
894
895    /**
896     * Add watchlist fields to the query, and the relevant join.
897     * If watchlist expiry is disabled, the we_expiry field will be omitted.
898     *
899     * @param string[] $fields The fields to add
900     * @return $this
901     */
902    public function watchlistFields(
903        $fields = [ 'wl_user', 'wl_notificationtimestamp', 'we_expiry' ]
904    ) {
905        $this->prepareCallbacks['watchlistFields'] = function () use ( $fields ) {
906            $wlFields = array_diff( $fields, [ 'we_expiry' ] );
907            $weFields = array_intersect( $fields, [ 'we_expiry' ] );
908            if ( $wlFields ) {
909                $this->fields( $wlFields );
910            }
911            $this->joinForFields( 'watchlist' )->weakLeft();
912            if ( $weFields && $this->config->get( MainConfigNames::WatchlistExpiry ) ) {
913                $this->fields( $weFields );
914                $this->joinForFields( 'watchlist_expiry' )->weakLeft();
915            }
916        };
917        return $this;
918    }
919
920    /**
921     * Add the rev_deleted and rev_slot_pair fields, used by ApiQueryRecentChanges
922     * to deliver SHA-1 hashes for modified content.
923     *
924     * @return self
925     */
926    public function sha1Fields() {
927        $this->sqbMutators['sha1Fields'] = $this->applySha1Fields( ... );
928        return $this;
929    }
930
931    private function applySha1Fields( SelectQueryBuilder $query ) {
932        $pairExpr = $this->db->buildGroupConcat(
933            $this->db->buildConcat( [ 'sr.role_name', $this->db->addQuotes( ':' ), 'c.content_sha1' ] ),
934            ','
935        );
936        $revSha1Subquery = $this->db->newSelectQueryBuilder()
937            ->select( [
938                'rev_id',
939                'rev_deleted',
940                'rev_slot_pairs' => $pairExpr,
941            ] )
942            ->from( 'revision' )
943            ->join( 'slots', 's', [ 'rev_id = s.slot_revision_id' ] )
944            ->join( 'content', 'c', [ 's.slot_content_id = c.content_id' ] )
945            ->join( 'slot_roles', 'sr', [ 's.slot_role_id = sr.role_id' ] )
946            ->groupBy( [ 'rev_id', 'rev_deleted' ] )
947            ->caller( __METHOD__ );
948
949        $query->leftJoin(
950            $revSha1Subquery,
951            'revsha1',
952            [ 'rc_this_oldid = revsha1.rev_id' ]
953        );
954        $query->fields( [
955            'rev_deleted' => 'revsha1.rev_deleted',
956            'rev_slot_pairs' => 'revsha1.rev_slot_pairs'
957        ] );
958    }
959
960    /**
961     * Add the page_is_redirect field
962     *
963     * @return $this
964     */
965    public function addRedirectField(): self {
966        $this->getRedirectFilter()->capture();
967        return $this;
968    }
969
970    /**
971     * Add CommentStore fields: rc_comment_text, rc_comment_data, rc_comment_cid.
972     *
973     * Note that recentChangeFields() also adds rc_comment_text and
974     * rc_comment_data, but for comment_id it uses the alias rc_comment_id.
975     * If you call both then you will get both aliases. But the joins will be
976     * deduplicated.
977     *
978     * @return $this
979     */
980    public function commentFields(): self {
981        $this->prepareCallbacks['commentFields'] = function () {
982            $this->joinForFields( 'comment' )->straight();
983            $this->fields( [
984                'rc_comment_text' => 'recentchanges_comment.comment_text',
985                'rc_comment_data' => 'recentchanges_comment.comment_data',
986                'rc_comment_cid' => 'recentchanges_comment.comment_id'
987            ] );
988        };
989        return $this;
990    }
991
992    /**
993     * Add the we_expiry field and its related join, if watchlist expiry is enabled
994     *
995     * @return $this
996     */
997    public function maybeAddWatchlistExpiryField(): self {
998        if ( $this->config->get( MainConfigNames::WatchlistExpiry ) ) {
999            $this->getWatchlistExpiryJoinModule()->forFields( $this )->weakLeft();
1000            $this->fields( 'we_expiry' );
1001        }
1002        return $this;
1003    }
1004
1005    /**
1006     * Add a callback which will be called when building an SQL query. It should
1007     * have a signature like
1008     *
1009     *    function mutator( &$tables, &$fields, &$conds, &$options, &$join_conds )
1010     *
1011     * @see IReadableDatabase::select()
1012     *
1013     * The mutator function may either return void, or it may return false to
1014     * indicate that the forceEmptySet flag should be set.
1015     *
1016     * This should not be used in new code.
1017     *
1018     * @param callable $callback
1019     * @return $this
1020     */
1021    public function legacyMutator( callable $callback ) {
1022        $this->legacyMutators[] = $callback;
1023        return $this;
1024    }
1025
1026    /**
1027     * Add a callback which will be called with a SelectQueryBuilder during
1028     * query construction. It should have a signature like
1029     *
1030     *   function mutator( SelectQueryBuilder $queryBuilder ): void
1031     *
1032     * The parameter may optionally be passed by reference and may be
1033     * reassigned.
1034     *
1035     * Instead consider integrating the functionality with this class.
1036     *
1037     * @param callable $callback
1038     * @return $this
1039     */
1040    public function sqbMutator( callable $callback ) {
1041        $this->sqbMutators[] = $callback;
1042        return $this;
1043    }
1044
1045    /**
1046     * Execute the query and return the result.
1047     *
1048     * @return ChangesListResult
1049     */
1050    public function fetchResult(): ChangesListResult {
1051        $this->prepare();
1052        if ( $this->isEmptySet() ) {
1053            return $this->newResult();
1054        }
1055
1056        $shouldPartition = $this->shouldDoPartitioning();
1057        if ( $shouldPartition ) {
1058            $this->prepareEmulatedUnion();
1059        }
1060
1061        $sqb = $this->createQueryBuilder();
1062        if ( !$shouldPartition ) {
1063            $this->applyTimestampFilter( $sqb );
1064        }
1065        $sqb = $this->applyMutators( $sqb );
1066        if ( !$sqb || $this->isEmptySet() ) {
1067            return $this->newResult();
1068        }
1069
1070        $queries = $this->applyLinkTarget( $sqb );
1071
1072        $timer = $this->statsFactory->getTiming( 'ChangesListQuery_query_seconds' )
1073            ->setLabel( 'caller', $this->caller ?? 'unknown' )
1074            ->setLabel( 'union', (string)count( $queries ) )
1075            ->start();
1076
1077        if ( $shouldPartition ) {
1078            $timer->setLabel( 'strategy', 'partition' );
1079            $res = $this->doPartitionUnion( $queries );
1080        } else {
1081            $timer->setLabel( 'strategy', 'simple' );
1082            $res = $this->maybeEmulateUnion( $queries );
1083        }
1084
1085        $timer->stop();
1086
1087        return $this->newResult( $res );
1088    }
1089
1090    /**
1091     * Call all modules asking them to populate fields, joins, etc.
1092     */
1093    private function prepare() {
1094        if ( $this->linkTables ) {
1095            $this->adjustDensity( self::DENSITY_LINKS )
1096                ->joinOrderHint( self::JOIN_ORDER_OTHER );
1097        }
1098        if ( $this->audience !== null ) {
1099            $this->getChangeTagsFilter()->setAudience( $this->audience );
1100        }
1101        foreach ( $this->filterModules as $module ) {
1102            $module->prepareQuery( $this->db, $this );
1103        }
1104        foreach ( $this->prepareCallbacks as $callback ) {
1105            $callback();
1106        }
1107        $this->prepareAudienceCondition( $this->audience );
1108        if ( count( $this->linkTables ) > 1 ) {
1109            $this->prepareEmulatedUnion();
1110        }
1111    }
1112
1113    /**
1114     * Add fields needed to support an emulated union
1115     */
1116    private function prepareEmulatedUnion() {
1117        $this->preparedEmulatedUnion = true;
1118        $this->fields( [ 'rc_timestamp', 'rc_id' ] );
1119    }
1120
1121    /**
1122     * @param stdClass[]|IResultWrapper $rows
1123     * @return ChangesListResult
1124     */
1125    private function newResult( $rows = [] ): ChangesListResult {
1126        return new ChangesListResult( $rows, $this->getHighlightsFromRow( ... ) );
1127    }
1128
1129    /**
1130     * Add conditions such that rows that cannot be viewed by the given authority
1131     * will not be returned.
1132     *
1133     * @param Authority|null $authority
1134     */
1135    private function prepareAudienceCondition( ?Authority $authority ) {
1136        if ( $this->excludeDeletedUser ) {
1137            if ( !$authority || !$authority->isAllowed( 'deletedhistory' ) ) {
1138                $bitmask = RevisionRecord::DELETED_USER;
1139            } elseif ( !$authority->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
1140                $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
1141            } else {
1142                $bitmask = 0;
1143            }
1144            if ( $bitmask ) {
1145                $this->where( new RawSQLExpression(
1146                    $this->db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask"
1147                ) );
1148            }
1149        }
1150        if ( $this->excludeDeletedAction ) {
1151            // Log entries with DELETED_ACTION must not show up unless the user has
1152            // the necessary rights.
1153            if ( !$authority || !$authority->isAllowed( 'deletedhistory' ) ) {
1154                $bitmask = LogPage::DELETED_ACTION;
1155            } elseif ( !$authority->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
1156                $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
1157            } else {
1158                $bitmask = 0;
1159            }
1160            if ( $bitmask ) {
1161                $this->where( $this->db->expr( 'rc_source', '!=', RecentChange::SRC_LOG )
1162                    ->orExpr( new RawSQLExpression(
1163                        $this->db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask"
1164                    ) )
1165                );
1166            }
1167        }
1168    }
1169
1170    private function createQueryBuilder(): SelectQueryBuilder {
1171        $sqb = $this->db->newSelectQueryBuilder()
1172            ->select( $this->getUniqueFields() )
1173            ->from( 'recentchanges' )
1174            ->where( $this->conds );
1175
1176        $this->applyOptions( $sqb );
1177
1178        foreach ( $this->joinModules as $join ) {
1179            $join->prepare( $sqb );
1180        }
1181        return $sqb;
1182    }
1183
1184    /**
1185     * Set the ORDER BY, LIMIT, etc. on a query
1186     *
1187     * @param SelectQueryBuilder $sqb
1188     */
1189    private function applyOptions( SelectQueryBuilder $sqb ) {
1190        if ( $this->distinct ) {
1191            $sqb->distinct();
1192            // In order to prevent DISTINCT from causing query performance problems,
1193            // we have to GROUP BY the primary key.
1194            $sqb->groupBy( [ 'rc_timestamp', 'rc_id' ] );
1195        }
1196        $dir = $this->sort === self::SORT_TIMESTAMP_ASC ? 'ASC' : 'DESC';
1197        $sqb->orderBy( [ "rc_timestamp $dir", "rc_id $dir" ] );
1198
1199        $sqb->caller( $this->caller ?? __CLASS__ );
1200        if ( $this->limit !== null ) {
1201            $sqb->limit( $this->limit );
1202        }
1203        if ( $this->maxExecutionTime !== null ) {
1204            $sqb->setMaxExecutionTime( $this->maxExecutionTime );
1205        }
1206    }
1207
1208    /**
1209     * Add conditions on rc_timestamp and rc_id
1210     *
1211     * @param SelectQueryBuilder $sqb
1212     */
1213    private function applyTimestampFilter( SelectQueryBuilder $sqb ) {
1214        if ( $this->minTimestamp !== null ) {
1215            $sqb->andWhere( $this->db->expr( 'rc_timestamp', '>=',
1216                $this->db->timestamp( $this->minTimestamp ) ) );
1217        }
1218        $this->applyStartOrEnd( $sqb, true, $this->startTimestamp, $this->startId );
1219        $this->applyStartOrEnd( $sqb, false, $this->endTimestamp, $this->endId );
1220    }
1221
1222    /**
1223     * @param SelectQueryBuilder $sqb
1224     * @param bool $isStart True for the start, false for the end
1225     * @param string $ts
1226     * @param int $id
1227     */
1228    private function applyStartOrEnd( SelectQueryBuilder $sqb, $isStart, $ts, $id ) {
1229        if ( $ts === null ) {
1230            return;
1231        }
1232        $op = ( $isStart === ( $this->sort === self::SORT_TIMESTAMP_ASC ) ) ? '>=' : '<=';
1233        $conds = [ 'rc_timestamp' => $this->db->timestamp( $ts ) ];
1234        if ( $id !== null ) {
1235            $conds['rc_id'] = $id;
1236        }
1237        $sqb->andWhere( $this->db->buildComparison( $op, $conds ) );
1238    }
1239
1240    /**
1241     * Get the field list, and deduplicate it.
1242     *
1243     * @return array
1244     */
1245    private function getUniqueFields() {
1246        $seen = [];
1247        $fields = [];