Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.83% covered (warning)
64.83%
389 / 600
40.68% covered (danger)
40.68%
24 / 59
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiPageSet
64.94% covered (warning)
64.94%
389 / 599
40.68% covered (danger)
40.68%
24 / 59
2345.65
0.00% covered (danger)
0.00%
0 / 1
 addValues
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 executeDryRun
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 executeInternal
17.46% covered (danger)
17.46%
11 / 63
0.00% covered (danger)
0.00%
0 / 1
268.99
 isResolvingRedirects
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDataSource
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 requestField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageTableFields
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 getAllTitlesByNamespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitleCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGoodTitlesByNamespace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGoodPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGoodTitleCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMissingTitlesByNamespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMissingPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGoodAndMissingTitlesByNamespace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGoodAndMissingPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInvalidTitlesAndReasons
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMissingPageIDs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRedirectTargets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRedirectTitlesAsResult
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
8.51
 getNormalizedTitles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNormalizedTitlesAsResult
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 getConvertedTitles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConvertedTitlesAsResult
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getInterwikiTitles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInterwikiTitlesAsResult
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
5.31
 getInvalidTitlesAndRevisions
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
11.04
 getRevisionIDs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLiveRevisionIDs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDeletedRevisionIDs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMissingRevisionIDs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMissingRevisionIDsAsResult
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
6.74
 getSpecialPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 populateFromTitles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 populateFromPageIDs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 populateFromQueryResult
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 populateFromRevisionIDs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processDbRow
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 initFromTitles
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 initFromPageIds
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 initFromQueryResult
80.65% covered (warning)
80.65%
25 / 31
0.00% covered (danger)
0.00%
0 / 1
15.42
 initFromRevIDs
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
132
 resolvePendingRedirects
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 loadRedirectTargets
95.45% covered (success)
95.45%
42 / 44
0.00% covered (danger)
0.00%
0 / 1
11
 getCacheMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processTitlesArray
96.61% covered (success)
96.61%
57 / 59
0.00% covered (danger)
0.00%
0 / 1
24
 setGeneratorData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setRedirectMergePolicy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveRedirectTitleDest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 populateGeneratorData
64.44% covered (warning)
64.44%
29 / 45
0.00% covered (danger)
0.00%
0 / 1
40.82
 getDB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
92.50% covered (success)
92.50%
37 / 40
0.00% covered (danger)
0.00%
0 / 1
4.01
 handleParamNormalization
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getGenerators
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Copyright Â© 2006, 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Api;
10
11use MediaWiki\Api\Validator\SubmoduleDef;
12use MediaWiki\Cache\GenderCache;
13use MediaWiki\Cache\LinkBatch;
14use MediaWiki\Cache\LinkBatchFactory;
15use MediaWiki\Cache\LinkCache;
16use MediaWiki\Context\DerivativeContext;
17use MediaWiki\Language\ILanguageConverter;
18use MediaWiki\Language\Language;
19use MediaWiki\Language\LanguageConverter;
20use MediaWiki\Linker\LinkTarget;
21use MediaWiki\MainConfigNames;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\Message\Message;
24use MediaWiki\Page\PageIdentity;
25use MediaWiki\Page\PageReference;
26use MediaWiki\Request\FauxRequest;
27use MediaWiki\SpecialPage\RedirectSpecialArticle;
28use MediaWiki\SpecialPage\SpecialPageFactory;
29use MediaWiki\Title\MalformedTitleException;
30use MediaWiki\Title\NamespaceInfo;
31use MediaWiki\Title\Title;
32use MediaWiki\Title\TitleFactory;
33use stdClass;
34use Wikimedia\Message\ListType;
35use Wikimedia\ParamValidator\ParamValidator;
36use Wikimedia\Rdbms\IReadableDatabase;
37use Wikimedia\Rdbms\IResultWrapper;
38
39/**
40 * This class contains a list of pages that the client has requested.
41 * Initially, when the client passes in titles=, pageids=, or revisions=
42 * parameter, an instance of the ApiPageSet class will normalize titles,
43 * determine if the pages/revisions exist, and prefetch any additional page
44 * data requested.
45 *
46 * When a generator is used, the result of the generator will become the input
47 * for the second instance of this class, and all subsequent actions will use
48 * the second instance for all their work.
49 *
50 * @ingroup API
51 * @since 1.21 derives from ApiBase instead of ApiQueryBase
52 */
53class ApiPageSet extends ApiBase {
54    /**
55     * Constructor flag: The new instance of ApiPageSet will ignore the 'generator=' parameter
56     * @since 1.21
57     */
58    private const DISABLE_GENERATORS = 1;
59
60    /** @var ApiBase used for getDb() call */
61    private $mDbSource;
62
63    /** @var array */
64    private $mParams;
65
66    /** @var bool */
67    private $mResolveRedirects;
68
69    /** @var bool */
70    private $mConvertTitles;
71
72    /** @var bool */
73    private $mAllowGenerator;
74
75    /** @var array<int,array<string,int>> [ns][dbkey] => page_id or negative when missing */
76    private $mAllPages = [];
77
78    /** @var Title[] */
79    private $mTitles = [];
80
81    /** @var array<int,array<string,int>> [ns][dbkey] => page_id or negative when missing */
82    private $mGoodAndMissingPages = [];
83
84    /** @var array<int,array<string,int>> [ns][dbkey] => page_id */
85    private $mGoodPages = [];
86
87    /** @var array<int,Title> */
88    private $mGoodTitles = [];
89
90    /** @var array<int,array<string,int>> [ns][dbkey] => fake page_id */
91    private $mMissingPages = [];
92
93    /** @var array<int,Title> */
94    private $mMissingTitles = [];
95
96    /** @var array<int,array{title:string,invalidreason:array}> [fake_page_id] => [ 'title' => $title, 'invalidreason' => $reason ] */
97    private $mInvalidTitles = [];
98
99    /** @var int[] */
100    private $mMissingPageIDs = [];
101
102    /** @var array<string,Title> */
103    private $mRedirectTitles = [];
104
105    /** @var array<int,Title> */
106    private $mSpecialTitles = [];
107
108    /** @var array<int,array<string,int>> separate from mAllPages to avoid breaking getAllTitlesByNamespace() */
109    private $mAllSpecials = [];
110
111    /** @var array<string,string> */
112    private $mNormalizedTitles = [];
113
114    /** @var array<string,string> */
115    private $mInterwikiTitles = [];
116
117    /** @var array<int,Title> */
118    private $mPendingRedirectIDs = [];
119
120    /** @var array<string,array{Title,Title}> [dbkey] => [ Title $from, Title $to ] */
121    private $mPendingRedirectSpecialPages = [];
122
123    /** @var array<string,Title> */
124    private $mResolvedRedirectTitles = [];
125
126    /** @var array<string,string> */
127    private $mConvertedTitles = [];
128
129    /** @var array<int,int> Array of revID (int) => pageID (int) */
130    private $mGoodRevIDs = [];
131
132    /** @var array<int,int> Array of revID (int) => pageID (int) */
133    private $mLiveRevIDs = [];
134
135    /** @var array<int,int> Array of revID (int) => pageID (int) */
136    private $mDeletedRevIDs = [];
137
138    /** @var int[] */
139    private $mMissingRevIDs = [];
140
141    /** @var array<int,array<string,array>> [ns][dbkey] => data array */
142    private $mGeneratorData = [];
143
144    /** @var int */
145    private $mFakePageId = -1;
146
147    /** @var string */
148    private $mCacheMode = 'public';
149
150    /** @var array<string,array<int,mixed>> [fieldName][pageId] => value */
151    private $mRequestedPageFields = [];
152
153    /** @var int */
154    private $mDefaultNamespace;
155
156    /** @var callable|null */
157    private $mRedirectMergePolicy;
158
159    /** @var array<string,string>|null see getGenerators() */
160    private static $generators = null;
161
162    private Language $contentLanguage;
163    private LinkCache $linkCache;
164    private NamespaceInfo $namespaceInfo;
165    private GenderCache $genderCache;
166    private LinkBatchFactory $linkBatchFactory;
167    private TitleFactory $titleFactory;
168    private ILanguageConverter $languageConverter;
169    private SpecialPageFactory $specialPageFactory;
170
171    /**
172     * Add all items from $values into the result
173     * @param array &$result Output
174     * @param array $values Values to add
175     * @param string[] $flags The names of boolean flags to mark this element
176     * @param string|null $name If given, name of the value
177     */
178    private static function addValues( array &$result, $values, $flags = [], $name = null ) {
179        foreach ( $values as $val ) {
180            if ( $val instanceof Title ) {
181                $v = [];
182                ApiQueryBase::addTitleInfo( $v, $val );
183            } elseif ( $name !== null ) {
184                $v = [ $name => $val ];
185            } else {
186                $v = $val;
187            }
188            foreach ( $flags as $flag ) {
189                $v[$flag] = true;
190            }
191            $result[] = $v;
192        }
193    }
194
195    /**
196     * @param ApiBase $dbSource Module implementing getDB().
197     *        Allows PageSet to reuse existing db connection from the shared state like ApiQuery.
198     * @param int $flags Zero or more flags like DISABLE_GENERATORS
199     * @param int $defaultNamespace The namespace to use if none is specified by a prefix.
200     * @since 1.21 accepts $flags instead of two boolean values
201     */
202    public function __construct( ApiBase $dbSource, $flags = 0, $defaultNamespace = NS_MAIN ) {
203        parent::__construct( $dbSource->getMain(), $dbSource->getModuleName() );
204        $this->mDbSource = $dbSource;
205        $this->mAllowGenerator = ( $flags & self::DISABLE_GENERATORS ) == 0;
206        $this->mDefaultNamespace = $defaultNamespace;
207
208        $this->mParams = $this->extractRequestParams();
209        $this->mResolveRedirects = $this->mParams['redirects'];
210        $this->mConvertTitles = $this->mParams['converttitles'];
211
212        // Needs service injection - T283314
213        $services = MediaWikiServices::getInstance();
214        $this->contentLanguage = $services->getContentLanguage();
215        $this->linkCache = $services->getLinkCache();
216        $this->namespaceInfo = $services->getNamespaceInfo();
217        $this->genderCache = $services->getGenderCache();
218        $this->linkBatchFactory = $services->getLinkBatchFactory();
219        $this->titleFactory = $services->getTitleFactory();
220        $this->languageConverter = $services->getLanguageConverterFactory()
221            ->getLanguageConverter( $this->contentLanguage );
222        $this->specialPageFactory = $services->getSpecialPageFactory();
223    }
224
225    /**
226     * In case execute() is not called, call this method to mark all relevant parameters as used
227     * This prevents unused parameters from being reported as warnings
228     */
229    public function executeDryRun() {
230        $this->executeInternal( true );
231    }
232
233    /**
234     * Populate the PageSet from the request parameters.
235     */
236    public function execute() {
237        $this->executeInternal( false );
238    }
239
240    /**
241     * Populate the PageSet from the request parameters.
242     * @param bool $isDryRun If true, instantiates generator, but only to mark
243     *    relevant parameters as used
244     */
245    private function executeInternal( $isDryRun ) {
246        $generatorName = $this->mAllowGenerator ? $this->mParams['generator'] : null;
247        if ( $generatorName !== null ) {
248            $dbSource = $this->mDbSource;
249            if ( !$dbSource instanceof ApiQuery ) {
250                // If the parent container of this pageset is not ApiQuery, we must create it to run generator
251                $dbSource = $this->getMain()->getModuleManager()->getModule( 'query' );
252            }
253            $generator = $dbSource->getModuleManager()->getModule( $generatorName, null, true );
254            if ( $generator === null ) {
255                $this->dieWithError( [ 'apierror-badgenerator-unknown', $generatorName ], 'badgenerator' );
256            }
257            if ( !$generator instanceof ApiQueryGeneratorBase ) {
258                $this->dieWithError( [ 'apierror-badgenerator-notgenerator', $generatorName ], 'badgenerator' );
259            }
260            // Create a temporary pageset to store generator's output,
261            // add any additional fields generator may need, and execute pageset to populate titles/pageids
262            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
263            $tmpPageSet = new ApiPageSet( $dbSource, self::DISABLE_GENERATORS );
264            $generator->setGeneratorMode( $tmpPageSet );
265            $this->mCacheMode = $generator->getCacheMode( $generator->extractRequestParams() );
266
267            if ( !$isDryRun ) {
268                $generator->requestExtraData( $tmpPageSet );
269            }
270            $tmpPageSet->executeInternal( $isDryRun );
271
272            // populate this pageset with the generator output
273            if ( !$isDryRun ) {
274                $generator->executeGenerator( $this );
275
276                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
277                $this->getHookRunner()->onAPIQueryGeneratorAfterExecute( $generator, $this );
278            } else {
279                // Prevent warnings from being reported on these parameters
280                $main = $this->getMain();
281                foreach ( $generator->extractRequestParams() as $paramName => $param ) {
282                    $main->markParamsUsed( $generator->encodeParamName( $paramName ) );
283                }
284            }
285
286            if ( !$isDryRun ) {
287                $this->resolvePendingRedirects();
288            }
289        } else {
290            // Only one of the titles/pageids/revids is allowed at the same time
291            $dataSource = null;
292            if ( isset( $this->mParams['titles'] ) ) {
293                $dataSource = 'titles';
294            }
295            if ( isset( $this->mParams['pageids'] ) ) {
296                if ( $dataSource !== null ) {
297                    $this->dieWithError(
298                        [
299                            'apierror-invalidparammix-cannotusewith',
300                            $this->encodeParamName( 'pageids' ),
301                            $this->encodeParamName( $dataSource )
302                        ],
303                        'multisource'
304                    );
305                }
306                $dataSource = 'pageids';
307            }
308            if ( isset( $this->mParams['revids'] ) ) {
309                if ( $dataSource !== null ) {
310                    $this->dieWithError(
311                        [
312                            'apierror-invalidparammix-cannotusewith',
313                            $this->encodeParamName( 'revids' ),
314                            $this->encodeParamName( $dataSource )
315                        ],
316                        'multisource'
317                    );
318                }
319                $dataSource = 'revids';
320            }
321
322            if ( !$isDryRun ) {
323                // Populate page information with the original user input
324                switch ( $dataSource ) {
325                    case 'titles':
326                        $this->initFromTitles( $this->mParams['titles'] );
327                        break;
328                    case 'pageids':
329                        $this->initFromPageIds( $this->mParams['pageids'] );
330                        break;
331                    case 'revids':
332                        if ( $this->mResolveRedirects ) {
333                            $this->addWarning( 'apiwarn-redirectsandrevids' );
334                        }
335                        $this->mResolveRedirects = false;
336                        $this->initFromRevIDs( $this->mParams['revids'] );
337                        break;
338                    default:
339                        // Do nothing - some queries do not need any of the data sources.
340                        break;
341                }
342            }
343        }
344    }
345
346    /**
347     * Check whether this PageSet is resolving redirects
348     * @return bool
349     */
350    public function isResolvingRedirects() {
351        return $this->mResolveRedirects;
352    }
353
354    /**
355     * Return the parameter name that is the source of data for this PageSet
356     *
357     * If multiple source parameters are specified (e.g. titles and pageids),
358     * one will be named arbitrarily.
359     *
360     * @return string|null
361     */
362    public function getDataSource() {
363        if ( $this->mAllowGenerator && isset( $this->mParams['generator'] ) ) {
364            return 'generator';
365        }
366        if ( isset( $this->mParams['titles'] ) ) {
367            return 'titles';
368        }
369        if ( isset( $this->mParams['pageids'] ) ) {
370            return 'pageids';
371        }
372        if ( isset( $this->mParams['revids'] ) ) {
373            return 'revids';
374        }
375
376        return null;
377    }
378
379    /**
380     * Request an additional field from the page table.
381     * Must be called before execute()
382     * @param string $fieldName A page table field, e.g. "page_touched"
383     */
384    public function requestField( $fieldName ) {
385        $this->mRequestedPageFields[$fieldName] = [];
386    }
387
388    /**
389     * Get the values of one of the previously requested page table fields. Can only be used
390     * after execute() and only for fields previously requested through requestField().
391     * @param string $fieldName A page table field, e.g. "page_touched"
392     * @return array<int,mixed> Field values per page id, initialized only after execute()
393     */
394    public function getCustomField( $fieldName ) {
395        return $this->mRequestedPageFields[$fieldName];
396    }
397
398    /**
399     * Get the fields that have to be queried from the page table:
400     * the ones requested through requestField() and a few basic ones
401     * we always need
402     * @return string[] Array of field names
403     */
404    public function getPageTableFields() {
405        // Ensure we get minimum required fields
406        // DON'T change this order
407        $pageFlds = [
408            'page_namespace' => null,
409            'page_title' => null,
410            'page_id' => null,
411        ];
412
413        if ( $this->mResolveRedirects ) {
414            $pageFlds['page_is_redirect'] = null;
415        }
416
417        $pageFlds['page_content_model'] = null;
418
419        if ( $this->getConfig()->get( MainConfigNames::PageLanguageUseDB ) ) {
420            $pageFlds['page_lang'] = null;
421        }
422
423        foreach ( LinkCache::getSelectFields() as $field ) {
424            $pageFlds[$field] = null;
425        }
426
427        $pageFlds = array_merge( $pageFlds, $this->mRequestedPageFields );
428
429        return array_keys( $pageFlds );
430    }
431
432    /**
433     * Returns an array [ns][dbkey] => page_id for all requested titles.
434     * page_id is a unique negative number in case title was not found.
435     * Invalid titles will also have negative page IDs and will be in namespace 0
436     * @return array<int,array<string,int>>
437     */
438    public function getAllTitlesByNamespace() {
439        return $this->mAllPages;
440    }
441
442    /**
443     * All existing and missing pages including redirects.
444     * Does not include special pages, interwiki links, and invalid titles.
445     * If redirects are resolved, both the redirect and the target will be included here.
446     *
447     * @since 1.37
448     * @return PageIdentity[]
449     */
450    public function getPages(): array {
451        return $this->mTitles;
452    }
453
454    /**
455     * Returns the number of unique pages (not revisions) in the set.
456     * @return int
457     */
458    public function getTitleCount() {
459        return count( $this->mTitles );
460    }
461
462    /**
463     * Returns an array [ns][dbkey] => page_id for all good titles.
464     * @return array<int,array<string,int>>
465     */
466    public function getGoodTitlesByNamespace() {
467        return $this->mGoodPages;
468    }
469
470    /**
471     * Pages that were found in the database, including redirects.
472     * If redirects are resolved, this will include existing redirect targets.
473     * @since 1.37
474     * @return array<int,PageIdentity> Array page_id (int) => PageIdentity (obj)
475     */
476    public function getGoodPages(): array {
477        return $this->mGoodTitles;
478    }
479
480    /**
481     * Returns the number of found unique pages (not revisions) in the set.
482     * @return int
483     */
484    public function getGoodTitleCount() {
485        return count( $this->mGoodTitles );
486    }
487
488    /**
489     * Returns an array [ns][dbkey] => fake_page_id for all missing titles.
490     * fake_page_id is a unique negative number.
491     * @return array<int,array<string,int>>
492     */
493    public function getMissingTitlesByNamespace() {
494        return $this->mMissingPages;
495    }
496
497    /**
498     * Pages that were NOT found in the database.
499     * The array's index will be negative for each item.
500     * If redirects are resolved, this will include missing redirect targets.
501     * @since 1.37
502     * @return PageIdentity[]
503     */
504    public function getMissingPages(): array {
505        return $this->mMissingTitles;
506    }
507
508    /**
509     * Returns an array [ns][dbkey] => page_id for all good and missing titles.
510     * @return array<int,array<string,int>>
511     */
512    public function getGoodAndMissingTitlesByNamespace() {
513        return $this->mGoodAndMissingPages;
514    }
515
516    /**
517     * Pages for good and missing titles.
518     * @since 1.37
519     * @return PageIdentity[]
520     */
521    public function getGoodAndMissingPages(): array {
522        return $this->mGoodTitles + $this->mMissingTitles;
523    }
524
525    /**
526     * Titles that were deemed invalid by Title::newFromText()
527     * The array's index will be unique and negative for each item
528     * @return array<int,array{title:string,invalidreason:array}>
529     */
530    public function getInvalidTitlesAndReasons() {
531        return $this->mInvalidTitles;
532    }
533
534    /**
535     * Page IDs that were not found in the database
536     * @return int[] Array of page IDs
537     */
538    public function getMissingPageIDs() {
539        return $this->mMissingPageIDs;
540    }
541
542    /**
543     * Get a list of redirect resolutions - maps a title to its redirect
544     * target.
545     * @since 1.37
546     * @return LinkTarget[]
547     */
548    public function getRedirectTargets(): array {
549        return $this->mRedirectTitles;
550    }
551
552    /**
553     * Get a list of redirect resolutions - maps a title to its redirect
554     * target. Includes generator data for redirect source when available.
555     * @param ApiResult|null $result
556     * @return string[][]
557     * @since 1.21
558     */
559    public function getRedirectTitlesAsResult( $result = null ) {
560        $values = [];
561        foreach ( $this->mRedirectTitles as $titleStrFrom => $titleTo ) {
562            $r = [
563                'from' => strval( $titleStrFrom ),
564                'to' => $titleTo->getPrefixedText(),
565            ];
566            if ( $titleTo->hasFragment() ) {
567                $r['tofragment'] = $titleTo->getFragment();
568            }
569            if ( $titleTo->isExternal() ) {
570                $r['tointerwiki'] = $titleTo->getInterwiki();
571            }
572            if ( isset( $this->mResolvedRedirectTitles[$titleStrFrom] ) ) {
573                $titleFrom = $this->mResolvedRedirectTitles[$titleStrFrom];
574                $ns = $titleFrom->getNamespace();
575                $dbkey = $titleFrom->getDBkey();
576                if ( isset( $this->mGeneratorData[$ns][$dbkey] ) ) {
577                    $r = array_merge( $this->mGeneratorData[$ns][$dbkey], $r );
578                }
579            }
580
581            $values[] = $r;
582        }
583        if ( $values && $result ) {
584            ApiResult::setIndexedTagName( $values, 'r' );
585        }
586
587        return $values;
588    }
589
590    /**
591     * Get a list of title normalizations - maps a title to its normalized
592     * version.
593     * @return array<string,string> Array of raw_prefixed_title (string) => prefixed_title (string)
594     */
595    public function getNormalizedTitles() {
596        return $this->mNormalizedTitles;
597    }
598
599    /**
600     * Get a list of title normalizations - maps a title to its normalized
601     * version in the form of result array.
602     * @param ApiResult|null $result
603     * @return string[][]
604     * @since 1.21
605     */
606    public function getNormalizedTitlesAsResult( $result = null ) {
607        $values = [];
608        foreach ( $this->getNormalizedTitles() as $rawTitleStr => $titleStr ) {
609            $encode = $this->contentLanguage->normalize( $rawTitleStr ) !== $rawTitleStr;
610            $values[] = [
611                'fromencoded' => $encode,
612                'from' => $encode ? rawurlencode( $rawTitleStr ) : $rawTitleStr,
613                'to' => $titleStr
614            ];
615        }
616        if ( $values && $result ) {
617            ApiResult::setIndexedTagName( $values, 'n' );
618        }
619
620        return $values;
621    }
622
623    /**
624     * Get a list of title conversions - maps a title to its converted
625     * version.
626     * @return string[] Array of raw_prefixed_title (string) => prefixed_title (string)
627     */
628    public function getConvertedTitles() {
629        return $this->mConvertedTitles;
630    }
631
632    /**
633     * Get a list of title conversions - maps a title to its converted
634     * version as a result array.
635     * @param ApiResult|null $result
636     * @return string[][] Array of (from, to) strings
637     * @since 1.21
638     */
639    public function getConvertedTitlesAsResult( $result = null ) {
640        $values = [];
641        foreach ( $this->getConvertedTitles() as $rawTitleStr => $titleStr ) {
642            $values[] = [
643                'from' => $rawTitleStr,
644                'to' => $titleStr
645            ];
646        }
647        if ( $values && $result ) {
648            ApiResult::setIndexedTagName( $values, 'c' );
649        }
650
651        return $values;
652    }
653
654    /**
655     * Get a list of interwiki titles - maps a title to its interwiki
656     * prefix.
657     * @return string[] Array of raw_prefixed_title (string) => interwiki_prefix (string)
658     */
659    public function getInterwikiTitles() {
660        return $this->mInterwikiTitles;
661    }
662
663    /**
664     * Get a list of interwiki titles - maps a title to its interwiki
665     * prefix as result.
666     * @param ApiResult|null $result
667     * @param bool $iwUrl
668     * @return string[][]
669     * @since 1.21
670     */
671    public function getInterwikiTitlesAsResult( $result = null, $iwUrl = false ) {
672        $values = [];
673        foreach ( $this->getInterwikiTitles() as $rawTitleStr => $interwikiStr ) {
674            $item = [
675                'title' => $rawTitleStr,
676                'iw' => $interwikiStr,
677            ];
678            if ( $iwUrl ) {
679                $title = $this->titleFactory->newFromText( $rawTitleStr );
680                $item['url'] = $title->getFullURL( '', false, PROTO_CURRENT );
681            }
682            $values[] = $item;
683        }
684        if ( $values && $result ) {
685            ApiResult::setIndexedTagName( $values, 'i' );
686        }
687
688        return $values;
689    }
690
691    /**
692     * Get an array of invalid/special/missing titles.
693     *
694     * @param string[] $invalidChecks List of types of invalid titles to include.
695     *   Recognized values are:
696     *   - invalidTitles: Titles and reasons from $this->getInvalidTitlesAndReasons()
697     *   - special: Titles from $this->getSpecialPages()
698     *   - missingIds: ids from $this->getMissingPageIDs()
699     *   - missingRevIds: ids from $this->getMissingRevisionIDs()
700     *   - missingTitles: Titles from $this->getMissingPages()
701     *   - interwikiTitles: Titles from $this->getInterwikiTitlesAsResult()
702     * @return array Array suitable for inclusion in the response
703     * @since 1.23
704     */
705    public function getInvalidTitlesAndRevisions( $invalidChecks = [ 'invalidTitles',
706        'special', 'missingIds', 'missingRevIds', 'missingTitles', 'interwikiTitles' ]
707    ) {
708        $result = [];
709        if ( in_array( 'invalidTitles', $invalidChecks ) ) {
710            self::addValues( $result, $this->getInvalidTitlesAndReasons(), [ 'invalid' ] );
711        }
712        if ( in_array( 'special', $invalidChecks ) ) {
713            $known = [];
714            $unknown = [];
715            foreach ( $this->mSpecialTitles as $title ) {
716                if ( $title->isKnown() ) {
717                    $known[] = $title;
718                } else {
719                    $unknown[] = $title;
720                }
721            }
722            self::addValues( $result, $unknown, [ 'special', 'missing' ] );
723            self::addValues( $result, $known, [ 'special' ] );
724        }
725        if ( in_array( 'missingIds', $invalidChecks ) ) {
726            self::addValues( $result, $this->getMissingPageIDs(), [ 'missing' ], 'pageid' );
727        }
728        if ( in_array( 'missingRevIds', $invalidChecks ) ) {
729            self::addValues( $result, $this->getMissingRevisionIDs(), [ 'missing' ], 'revid' );
730        }
731        if ( in_array( 'missingTitles', $invalidChecks ) ) {
732            $known = [];
733            $unknown = [];
734            foreach ( $this->mMissingTitles as $title ) {
735                if ( $title->isKnown() ) {
736                    $known[] = $title;
737                } else {
738                    $unknown[] = $title;
739                }
740            }
741            self::addValues( $result, $unknown, [ 'missing' ] );
742            self::addValues( $result, $known, [ 'missing', 'known' ] );
743        }
744        if ( in_array( 'interwikiTitles', $invalidChecks ) ) {
745            self::addValues( $result, $this->getInterwikiTitlesAsResult() );
746        }
747
748        return $result;
749    }
750
751    /**
752     * Get the list of valid revision IDs (requested with the revids= parameter)
753     * @return int[] Array of revID (int) => pageID (int)
754     */
755    public function getRevisionIDs() {
756        return $this->mGoodRevIDs;
757    }
758
759    /**
760     * Get the list of non-deleted revision IDs (requested with the revids= parameter)
761     * @return int[] Array of revID (int) => pageID (int)
762     */
763    public function getLiveRevisionIDs() {
764        return $this->mLiveRevIDs;
765    }
766
767    /**
768     * Get the list of revision IDs that were associated with deleted titles.
769     * @return int[] Array of revID (int) => pageID (int)
770     */
771    public function getDeletedRevisionIDs() {
772        return $this->mDeletedRevIDs;
773    }
774
775    /**
776     * Revision IDs that were not found in the database
777     * @return int[] Array of revision IDs
778     */
779    public function getMissingRevisionIDs() {
780        return $this->mMissingRevIDs;
781    }
782
783    /**
784     * Revision IDs that were not found in the database as result array.
785     * @param ApiResult|null $result
786     * @return array<int,array>
787     * @since 1.21
788     */
789    public function getMissingRevisionIDsAsResult( $result = null ) {
790        $values = [];
791        foreach ( $this->getMissingRevisionIDs() as $revid ) {
792            $values[$revid] = [
793                'revid' => $revid,
794                'missing' => true,
795            ];
796        }
797        if ( $values && $result ) {
798            ApiResult::setIndexedTagName( $values, 'rev' );
799        }
800
801        return $values;
802    }
803
804    /**
805     * Get the list of pages with negative namespace
806     * @since 1.37
807     * @return PageReference[]
808     */
809    public function getSpecialPages(): array {
810        return $this->mSpecialTitles;
811    }
812
813    /**
814     * Returns the number of revisions (requested with revids= parameter).
815     * @return int Number of revisions.
816     */
817    public function getRevisionCount() {
818        return count( $this->getRevisionIDs() );
819    }
820
821    /**
822     * Populate this PageSet
823     * @param string[]|LinkTarget[]|PageReference[] $titles
824     */
825    public function populateFromTitles( $titles ) {
826        $this->initFromTitles( $titles );
827    }
828
829    /**
830     * Populate this PageSet from a list of page IDs
831     * @param int[] $pageIDs
832     */
833    public function populateFromPageIDs( $pageIDs ) {
834        $this->initFromPageIds( $pageIDs );
835    }
836
837    /**
838     * Populate this PageSet from a rowset returned from the database
839     *
840     * Note that the query result must include the columns returned by
841     * $this->getPageTableFields().
842     *
843     * @param IReadableDatabase $db Unused since 2011
844     * @param IResultWrapper $queryResult
845     */
846    public function populateFromQueryResult( $db, $queryResult ) {
847        $this->initFromQueryResult( $queryResult );
848    }
849
850    /**
851     * Populate this PageSet from a list of revision IDs
852     * @param int[] $revIDs Array of revision IDs
853     */
854    public function populateFromRevisionIDs( $revIDs ) {
855        $this->initFromRevIDs( $revIDs );
856    }
857
858    /**
859     * Extract all requested fields from the row received from the database
860     * @param stdClass $row Result row
861     */
862    public function processDbRow( $row ) {
863        // Store Title object in various data structures
864        $title = $this->titleFactory->newFromRow( $row );
865
866        $this->linkCache->addGoodLinkObjFromRow( $title, $row );
867
868        $pageId = (int)$row->page_id;
869        $this->mAllPages[$row->page_namespace][$row->page_title] = $pageId;
870        $this->mTitles[] = $title;
871
872        if ( $this->mResolveRedirects && $row->page_is_redirect == '1' ) {
873            $this->mPendingRedirectIDs[$pageId] = $title;
874        } else {
875            $this->mGoodPages[$row->page_namespace][$row->page_title] = $pageId;
876            $this->mGoodAndMissingPages[$row->page_namespace][$row->page_title] = $pageId;
877            $this->mGoodTitles[$pageId] = $title;
878        }
879
880        foreach ( $this->mRequestedPageFields as $fieldName => &$fieldValues ) {
881            $fieldValues[$pageId] = $row->$fieldName;
882        }
883    }
884
885    /**
886     * This method populates internal variables with page information
887     * based on the given array of title strings.
888     *
889     * Steps:
890     * #1 For each title, get data from `page` table
891     * #2 If page was not found in the DB, store it as missing
892     *
893     * Additionally, when resolving redirects:
894     * #3 If no more redirects left, stop.
895     * #4 For each redirect, get its target from the `redirect` table.
896     * #5 Substitute the original LinkBatch object with the new list
897     * #6 Repeat from step #1
898     *
899     * @param string[]|LinkTarget[]|PageReference[] $titles
900     */
901    private function initFromTitles( $titles ) {
902        // Get validated and normalized title objects
903        $linkBatch = $this->processTitlesArray( $titles );
904        if ( $linkBatch->isEmpty() ) {
905            // There might be special-page redirects
906            $this->resolvePendingRedirects();
907            return;
908        }
909
910        $db = $this->getDB();
911
912        // Get pageIDs data from the `page` table
913        $res = $db->newSelectQueryBuilder()
914            ->select( $this->getPageTableFields() )
915            ->from( 'page' )
916            ->where( $linkBatch->constructSet( 'page', $db ) )
917            ->caller( __METHOD__ )
918            ->fetchResultSet();
919
920        // Hack: get the ns:titles stored in [ ns => [ titles ] ] format
921        $this->initFromQueryResult( $res, $linkBatch->data, true ); // process Titles
922
923        // Resolve any found redirects
924        $this->resolvePendingRedirects();
925    }
926
927    /**
928     * Does the same as initFromTitles(), but is based on page IDs instead
929     * @param int[] $pageids
930     * @param bool $filterIds Whether the IDs need filtering
931     */
932    private function initFromPageIds( $pageids, $filterIds = true ) {
933        if ( !$pageids ) {
934            return;
935        }
936
937        $pageids = array_map( 'intval', $pageids ); // paranoia
938        $remaining = array_fill_keys( $pageids, true );
939
940        if ( $filterIds ) {
941            $pageids = $this->filterIDs( [ [ 'page', 'page_id' ] ], $pageids );
942        }
943
944        $res = null;
945        if ( $pageids ) {
946            $db = $this->getDB();
947
948            // Get pageIDs data from the `page` table
949            $res = $db->newSelectQueryBuilder()
950                ->select( $this->getPageTableFields() )
951                ->from( 'page' )
952                ->where( [ 'page_id' => $pageids ] )
953                ->caller( __METHOD__ )
954                ->fetchResultSet();
955        }
956
957        $this->initFromQueryResult( $res, $remaining, false ); // process PageIDs
958
959        // Resolve any found redirects
960        $this->resolvePendingRedirects();
961    }
962
963    /**
964     * Iterate through the result of the query on 'page' table,
965     * and for each row create and store title object and save any extra fields requested.
966     * @param IResultWrapper|null $res DB Query result
967     * @param array|null &$remaining Array of either pageID or ns/title elements (optional).
968     *        If given, any missing items will go to $mMissingPageIDs and $mMissingTitles
969     * @param bool|null $processTitles Must be provided together with $remaining.
970     *        If true, treat $remaining as an array of [ns][title]
971     *        If false, treat it as an array of [pageIDs]
972     */
973    private function initFromQueryResult( $res, &$remaining = null, $processTitles = null ) {
974        if ( $remaining !== null && $processTitles === null ) {
975            ApiBase::dieDebug( __METHOD__, 'Missing $processTitles parameter when $remaining is provided' );
976        }
977
978        $usernames = [];
979        if ( $res ) {
980            foreach ( $res as $row ) {
981                $pageId = (int)$row->page_id;
982
983                // Remove found page from the list of remaining items
984                if ( $remaining ) {
985                    if ( $processTitles ) {
986                        unset( $remaining[$row->page_namespace][$row->page_title] );
987                    } else {
988                        unset( $remaining[$pageId] );
989                    }
990                }
991
992                // Store any extra fields requested by modules
993                $this->processDbRow( $row );
994
995                // Need gender information
996                if ( $this->namespaceInfo->hasGenderDistinction( $row->page_namespace ) ) {
997                    $usernames[] = $row->page_title;
998                }
999            }
1000        }
1001
1002        if ( $remaining ) {
1003            // Any items left in the $remaining list are added as missing
1004            if ( $processTitles ) {
1005                // The remaining titles in $remaining are non-existent pages
1006                foreach ( $remaining as $ns => $dbkeys ) {
1007                    foreach ( $dbkeys as $dbkey => $_ ) {
1008                        $title = $this->titleFactory->makeTitle( $ns, $dbkey );
1009                        $this->linkCache->addBadLinkObj( $title );
1010                        $this->mAllPages[$ns][$dbkey] = $this->mFakePageId;
1011                        $this->mMissingPages[$ns][$dbkey] = $this->mFakePageId;
1012                        $this->mGoodAndMissingPages[$ns][$dbkey] = $this->mFakePageId;
1013                        $this->mMissingTitles[$this->mFakePageId] = $title;
1014                        $this->mFakePageId--;
1015                        $this->mTitles[] = $title;
1016
1017                        // need gender information
1018                        if ( $this->namespaceInfo->hasGenderDistinction( $ns ) ) {
1019                            $usernames[] = $dbkey;
1020                        }
1021                    }
1022                }
1023            } else {
1024                // The remaining pageids do not exist
1025                if ( !$this->mMissingPageIDs ) {
1026                    $this->mMissingPageIDs = array_keys( $remaining );
1027                } else {
1028                    $this->mMissingPageIDs = array_merge( $this->mMissingPageIDs, array_keys( $remaining ) );
1029                }
1030            }
1031        }
1032
1033        // Get gender information
1034        $this->genderCache->doQuery( $usernames, __METHOD__ );
1035    }
1036
1037    /**
1038     * Does the same as initFromTitles(), but is based on revision IDs
1039     * instead
1040     * @param int[] $revids Array of revision IDs
1041     */
1042    private function initFromRevIDs( $revids ) {
1043        if ( !$revids ) {
1044            return;
1045        }
1046
1047        $revids = array_map( 'intval', $revids ); // paranoia
1048        $db = $this->getDB();
1049        $pageids = [];
1050        $remaining = array_fill_keys( $revids, true );
1051
1052        $revids = $this->filterIDs( [ [ 'revision', 'rev_id' ], [ 'archive', 'ar_rev_id' ] ], $revids );
1053        $goodRemaining = array_fill_keys( $revids, true );
1054
1055        if ( $revids ) {
1056            $fields = [ 'rev_id', 'rev_page' ];
1057
1058            // Get pageIDs data from the `page` table
1059            $res = $db->newSelectQueryBuilder()
1060                ->select( $fields )
1061                ->from( 'page' )
1062                ->where( [ 'rev_id' => $revids ] )
1063                ->join( 'revision', null, [ 'rev_page = page_id' ] )
1064                ->caller( __METHOD__ )
1065                ->fetchResultSet();
1066            foreach ( $res as $row ) {
1067                $revid = (int)$row->rev_id;
1068                $pageid = (int)$row->rev_page;
1069                $this->mGoodRevIDs[$revid] = $pageid;
1070                $this->mLiveRevIDs[$revid] = $pageid;
1071                $pageids[$pageid] = '';
1072                unset( $remaining[$revid] );
1073                unset( $goodRemaining[$revid] );
1074            }
1075        }
1076
1077        // Populate all the page information
1078        $this->initFromPageIds( array_keys( $pageids ), false );
1079
1080        // If the user can see deleted revisions, pull out the corresponding
1081        // titles from the archive table and include them too. We ignore
1082        // ar_page_id because deleted revisions are tied by title, not page_id.
1083        if ( $goodRemaining &&
1084            $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
1085
1086            $res = $db->newSelectQueryBuilder()
1087                ->select( [ 'ar_rev_id', 'ar_namespace', 'ar_title' ] )
1088                ->from( 'archive' )
1089                ->where( [ 'ar_rev_id' => array_keys( $goodRemaining ) ] )
1090                ->caller( __METHOD__ )
1091                ->fetchResultSet();
1092
1093            $titles = [];
1094            foreach ( $res as $row ) {
1095                $revid = (int)$row->ar_rev_id;
1096                $titles[$revid] = $this->titleFactory->makeTitle( $row->ar_namespace, $row->ar_title );
1097                unset( $remaining[$revid] );
1098            }
1099
1100            $this->initFromTitles( $titles );
1101
1102            foreach ( $titles as $revid => $title ) {
1103                $ns = $title->getNamespace();
1104                $dbkey = $title->getDBkey();
1105
1106                // Handle converted titles
1107                if ( !isset( $this->mAllPages[$ns][$dbkey] ) &&
1108                    isset( $this->mConvertedTitles[$title->getPrefixedText()] )
1109                ) {
1110                    $title = $this->titleFactory->newFromText( $this->mConvertedTitles[$title->getPrefixedText()] );
1111                    $ns = $title->getNamespace();
1112                    $dbkey = $title->getDBkey();
1113                }
1114
1115                if ( isset( $this->mAllPages[$ns][$dbkey] ) ) {
1116                    $this->mGoodRevIDs[$revid] = $this->mAllPages[$ns][$dbkey];
1117                    $this->mDeletedRevIDs[$revid] = $this->mAllPages[$ns][$dbkey];
1118                } else {
1119                    $remaining[$revid] = true;
1120                }
1121            }
1122        }
1123
1124        $this->mMissingRevIDs = array_keys( $remaining );
1125    }
1126
1127    /**
1128     * Resolve any redirects in the result if redirect resolution was
1129     * requested. This function is called repeatedly until all redirects
1130     * have been resolved.
1131     */
1132    private function resolvePendingRedirects() {
1133        if ( $this->mResolveRedirects ) {
1134            $db = $this->getDB();
1135
1136            // Repeat until all redirects have been resolved
1137            // The infinite loop is prevented by keeping all known pages in $this->mAllPages
1138            while ( $this->mPendingRedirectIDs || $this->mPendingRedirectSpecialPages ) {
1139                // Resolve redirects by querying the pagelinks table, and repeat the process
1140                // Create a new linkBatch object for the next pass
1141                $linkBatch = $this->loadRedirectTargets();
1142
1143                if ( $linkBatch->isEmpty() ) {
1144                    break;
1145                }
1146
1147                $set = $linkBatch->constructSet( 'page', $db );
1148                if ( $set === false ) {
1149                    break;
1150                }
1151
1152                // Get pageIDs data from the `page` table
1153                $res = $db->newSelectQueryBuilder()
1154                    ->select( $this->getPageTableFields() )
1155                    ->from( 'page' )
1156                    ->where( $set )
1157                    ->caller( __METHOD__ )
1158                    ->fetchResultSet();
1159
1160                // Hack: get the ns:titles stored in [ns => array(titles)] format
1161                $this->initFromQueryResult( $res, $linkBatch->data, true );
1162            }
1163        }
1164    }
1165
1166    /**
1167     * Get the targets of the pending redirects from the database
1168     *
1169     * Also creates entries in the redirect table for redirects that don't
1170     * have one.
1171     * @return LinkBatch
1172     */
1173    private function loadRedirectTargets() {
1174        $titlesToResolve = [];
1175        $db = $this->getDB();
1176
1177        if ( $this->mPendingRedirectIDs ) {
1178            $res = $db->newSelectQueryBuilder()
1179                ->select( [
1180                    'rd_from',
1181                    'rd_namespace',
1182                    'rd_fragment',
1183                    'rd_interwiki',
1184                    'rd_title'
1185                ] )
1186                ->from( 'redirect' )
1187                ->where( [ 'rd_from' => array_keys( $this->mPendingRedirectIDs ) ] )
1188                ->caller( __METHOD__ )
1189                ->fetchResultSet();
1190
1191            foreach ( $res as $row ) {
1192                $rdfrom = (int)$row->rd_from;
1193                $from = $this->mPendingRedirectIDs[$rdfrom]->getPrefixedText();
1194                $to = $this->titleFactory->makeTitle(
1195                    $row->rd_namespace,
1196                    $row->rd_title,
1197                    $row->rd_fragment,
1198                    $row->rd_interwiki
1199                );
1200                $this->mResolvedRedirectTitles[$from] = $this->mPendingRedirectIDs[$rdfrom];
1201                unset( $this->mPendingRedirectIDs[$rdfrom] );
1202                if ( $to->isExternal() ) {
1203                    $this->mInterwikiTitles[$to->getPrefixedText()] = $to->getInterwiki();
1204                } elseif ( !isset( $this->mAllPages[$to->getNamespace()][$to->getDBkey()] )
1205                    && !( $this->mConvertTitles && isset( $this->mConvertedTitles[$to->getPrefixedText()] ) )
1206                ) {
1207                    $titlesToResolve[] = $to;
1208                }
1209                $this->mRedirectTitles[$from] = $to;
1210            }
1211        }
1212
1213        if ( $this->mPendingRedirectSpecialPages ) {
1214            foreach ( $this->mPendingRedirectSpecialPages as [ $from, $to ] ) {
1215                /** @var Title $from */
1216                $fromKey = $from->getPrefixedText();
1217                $this->mResolvedRedirectTitles[$fromKey] = $from;
1218                $this->mRedirectTitles[$fromKey] = $to;
1219                if ( $to->isExternal() ) {
1220                    $this->mInterwikiTitles[$to->getPrefixedText()] = $to->getInterwiki();
1221                } elseif ( !isset( $this->mAllPages[$to->getNamespace()][$to->getDBkey()] ) ) {
1222                    $titlesToResolve[] = $to;
1223                }
1224            }
1225            $this->mPendingRedirectSpecialPages = [];
1226
1227            // Set private caching since we don't know what criteria the
1228            // special pages used to decide on these redirects.
1229            $this->mCacheMode = 'private';
1230        }
1231
1232        return $this->processTitlesArray( $titlesToResolve );
1233    }
1234
1235    /**
1236     * Get the cache mode for the data generated by this module.
1237     * All PageSet users should take into account whether this returns a more-restrictive
1238     * cache mode than the using module itself. For possible return values and other
1239     * details about cache modes, see ApiMain::setCacheMode()
1240     *
1241     * Public caching will only be allowed if *all* the modules that supply
1242     * data for a given request return a cache mode of public.
1243     *
1244     * @param array|null $params
1245     * @return string
1246     * @since 1.21
1247     */
1248    public function getCacheMode( $params = null ) {
1249        return $this->mCacheMode;
1250    }
1251
1252    /**
1253     * Given an array of title strings, convert them into Title objects.
1254     * Alternatively, an array of Title objects may be given.
1255     * This method validates access rights for the title,
1256     * and appends normalization values to the output.
1257     *
1258     * @param string[]|LinkTarget[]|PageReference[] $titles
1259     * @return LinkBatch
1260     */
1261    private function processTitlesArray( $titles ) {
1262        $linkBatch = $this->linkBatchFactory->newLinkBatch();
1263
1264        /** @var Title[] $titleObjects */
1265        $titleObjects = [];
1266        foreach ( $titles as $index => $title ) {
1267            if ( is_string( $title ) ) {
1268                try {
1269                    /** @var Title $titleObj */
1270                    $titleObj = $this->titleFactory->newFromTextThrow( $title, $this->mDefaultNamespace );
1271                } catch ( MalformedTitleException $ex ) {
1272                    // Handle invalid titles gracefully
1273                    if ( !isset( $this->mAllPages[0][$title] ) ) {
1274                        $this->mAllPages[0][$title] = $this->mFakePageId;
1275                        $this->mInvalidTitles[$this->mFakePageId] = [
1276                            'title' => $title,
1277                            'invalidreason' => $this->getErrorFormatter()->formatException( $ex, [ 'bc' => true ] ),
1278                        ];
1279                        $this->mFakePageId--;
1280                    }
1281                    continue; // There's nothing else we can do
1282                }
1283            } elseif ( $title instanceof LinkTarget ) {
1284                $titleObj = $this->titleFactory->newFromLinkTarget( $title );
1285            } else {
1286                $titleObj = $this->titleFactory->newFromPageReference( $title );
1287            }
1288
1289            $titleObjects[$index] = $titleObj;
1290        }
1291
1292        // Get gender information
1293        $this->genderCache->doTitlesArray( $titleObjects, __METHOD__ );
1294
1295        foreach ( $titleObjects as $index => $titleObj ) {
1296            $title = is_string( $titles[$index] ) ? $titles[$index] : false;
1297            $unconvertedTitle = $titleObj->getPrefixedText();
1298            $titleWasConverted = false;
1299            if ( $titleObj->isExternal() ) {
1300                // This title is an interwiki link.
1301                $this->mInterwikiTitles[$unconvertedTitle] = $titleObj->getInterwiki();
1302            } else {
1303                // Variants checking
1304                if (
1305                    $this->mConvertTitles
1306                    && $this->languageConverter->hasVariants()
1307                    && !$titleObj->exists()
1308                ) {
1309                    // ILanguageConverter::findVariantLink will modify titleText and
1310                    // titleObj into the canonical variant if possible
1311                    $titleText = $title !== false ? $title : $titleObj->getPrefixedText();
1312                    $this->languageConverter->findVariantLink( $titleText, $titleObj );
1313                    $titleWasConverted = $unconvertedTitle !== $titleObj->getPrefixedText();
1314                }
1315
1316                if ( $titleObj->getNamespace() < 0 ) {
1317                    // Handle Special and Media pages
1318                    $titleObj = $titleObj->fixSpecialName();
1319                    $ns = $titleObj->getNamespace();
1320                    $dbkey = $titleObj->getDBkey();
1321                    if ( !isset( $this->mAllSpecials[$ns][$dbkey] ) ) {
1322                        $this->mAllSpecials[$ns][$dbkey] = $this->mFakePageId;
1323                        $target = null;
1324                        if ( $ns === NS_SPECIAL && $this->mResolveRedirects ) {
1325                            $special = $this->specialPageFactory->getPage( $dbkey );
1326                            if ( $special instanceof RedirectSpecialArticle ) {
1327                                // Only RedirectSpecialArticle is intended to redirect to an article, other kinds of
1328                                // RedirectSpecialPage are probably applying weird URL parameters we don't want to
1329                                // handle.
1330                                $context = new DerivativeContext( $this );
1331                                $context->setTitle( $titleObj );
1332                                $context->setRequest( new FauxRequest );
1333                                $special->setContext( $context );
1334                                [ /* $alias */, $subpage ] = $this->specialPageFactory->resolveAlias( $dbkey );
1335                                $target = $special->getRedirect( $subpage );
1336                            }
1337                        }
1338                        if ( $target ) {
1339                            $this->mPendingRedirectSpecialPages[$dbkey] = [ $titleObj, $target ];
1340                        } else {
1341                            $this->mSpecialTitles[$this->mFakePageId] = $titleObj;
1342                            $this->mFakePageId--;
1343                        }
1344                    }
1345                } else {
1346                    // Regular page
1347                    $linkBatch->addObj( $titleObj );
1348                }
1349            }
1350
1351            // Make sure we remember the original title that was
1352            // given to us. This way the caller can correlate new
1353            // titles with the originally requested when e.g. the
1354            // namespace is localized or the capitalization is
1355            // different
1356            if ( $titleWasConverted ) {
1357                $this->mConvertedTitles[$unconvertedTitle] = $titleObj->getPrefixedText();
1358                // In this case the page can't be Special.
1359                if ( $title !== false && $title !== $unconvertedTitle ) {
1360                    $this->mNormalizedTitles[$title] = $unconvertedTitle;
1361                }
1362            } elseif ( $title !== false && $title !== $titleObj->getPrefixedText() ) {
1363                $this->mNormalizedTitles[$title] = $titleObj->getPrefixedText();
1364            }
1365        }
1366
1367        return $linkBatch;
1368    }
1369
1370    /**
1371     * Set data for a title.
1372     *
1373     * This data may be extracted into an ApiResult using
1374     * self::populateGeneratorData. This should generally be limited to
1375     * data that is likely to be particularly useful to end users rather than
1376     * just being a dump of everything returned in non-generator mode.
1377     *
1378     * Redirects here will *not* be followed, even if 'redirects' was
1379     * specified, since in the case of multiple redirects we can't know which
1380     * source's data to use on the target.
1381     *
1382     * @param PageReference|LinkTarget $title
1383     * @param array $data
1384     */
1385    public function setGeneratorData( $title, array $data ) {
1386        $ns = $title->getNamespace();
1387        $dbkey = $title->getDBkey();
1388        $this->mGeneratorData[$ns][$dbkey] = $data;
1389    }
1390
1391    /**
1392     * Controls how generator data about a redirect source is merged into
1393     * the generator data for the redirect target. When not set no data
1394     * is merged. Note that if multiple titles redirect to the same target
1395     * the order of operations is undefined.
1396     *
1397     * Example to include generated data from redirect in target, prefering
1398     * the data generated for the destination when there is a collision:
1399     * @code
1400     *   $pageSet->setRedirectMergePolicy( function( array $current, array $new ) {
1401     *       return $current + $new;
1402     *   } );
1403     * @endcode
1404     *
1405     * @param callable|null $callable Recieves two array arguments, first the
1406     *  generator data for the redirect target and second the generator data
1407     *  for the redirect source. Returns the resulting generator data to use
1408     *  for the redirect target.
1409     */
1410    public function setRedirectMergePolicy( $callable ) {
1411        $this->mRedirectMergePolicy = $callable;
1412    }
1413
1414    /**
1415     * Resolve the title a redirect points to.
1416     *
1417     * Will follow sequential redirects to find the final page. In
1418     * the case of a redirect cycle the original page will be returned.
1419     * self::resolvePendingRedirects must be executed before calling
1420     * this method.
1421     *
1422     * @param Title $titleFrom A title from $this->mResolvedRedirectTitles
1423     * @return Title
1424     */
1425    private function resolveRedirectTitleDest( Title $titleFrom ): Title {
1426        $seen = [];
1427        $dest = $titleFrom;
1428        while ( isset( $this->mRedirectTitles[$dest->getPrefixedText()] ) ) {
1429            $dest = $this->mRedirectTitles[$dest->getPrefixedText()];
1430            if ( isset( $seen[$dest->getPrefixedText()] ) ) {
1431                return $titleFrom;
1432            }
1433            $seen[$dest->getPrefixedText()] = true;
1434        }
1435        return $dest;
1436    }
1437
1438    /**
1439     * Populate the generator data for all titles in the result
1440     *
1441     * The page data may be inserted into an ApiResult object or into an
1442     * associative array. The $path parameter specifies the path within the
1443     * ApiResult or array to find the "pages" node.
1444     *
1445     * The "pages" node itself must be an associative array mapping the page ID
1446     * or fake page ID values returned by this pageset (see
1447     * self::getAllTitlesByNamespace() and self::getSpecialTitles()) to
1448     * associative arrays of page data. Each of those subarrays will have the
1449     * data from self::setGeneratorData() merged in.
1450     *
1451     * Data that was set by self::setGeneratorData() for pages not in the
1452     * "pages" node will be ignored.
1453     *
1454     * @param ApiResult|array &$result
1455     * @param array $path
1456     * @return bool Whether the data fit
1457     */
1458    public function populateGeneratorData( &$result, array $path = [] ) {
1459        if ( $result instanceof ApiResult ) {
1460            $data = $result->getResultData( $path );
1461            if ( $data === null ) {
1462                return true;
1463            }
1464        } else {
1465            $data = &$result;
1466            foreach ( $path as $key ) {
1467                if ( !isset( $data[$key] ) ) {
1468                    // Path isn't in $result, so nothing to add, so everything
1469                    // "fits"
1470                    return true;
1471                }
1472                $data = &$data[$key];
1473            }
1474        }
1475        foreach ( $this->mGeneratorData as $ns => $dbkeys ) {
1476            if ( $ns === NS_SPECIAL ) {
1477                $pages = [];
1478                foreach ( $this->mSpecialTitles as $id => $title ) {
1479                    $pages[$title->getDBkey()] = $id;
1480                }
1481            } else {
1482                if ( !isset( $this->mAllPages[$ns] ) ) {
1483                    // No known titles in the whole namespace. Skip it.
1484                    continue;
1485                }
1486                $pages = $this->mAllPages[$ns];
1487            }
1488            foreach ( $dbkeys as $dbkey => $genData ) {
1489                if ( !isset( $pages[$dbkey] ) ) {
1490                    // Unknown title. Forget it.
1491                    continue;
1492                }
1493                $pageId = $pages[$dbkey];
1494                if ( !isset( $data[$pageId] ) ) {
1495                    // $pageId didn't make it into the result. Ignore it.
1496                    continue;
1497                }
1498
1499                if ( $result instanceof ApiResult ) {
1500                    $path2 = array_merge( $path, [ $pageId ] );
1501                    foreach ( $genData as $key => $value ) {
1502                        if ( !$result->addValue( $path2, $key, $value ) ) {
1503                            return false;
1504                        }
1505                    }
1506                } else {
1507                    $data[$pageId] = array_merge( $data[$pageId], $genData );
1508                }
1509            }
1510        }
1511
1512        // Merge data generated about redirect titles into the redirect destination
1513        if ( $this->mRedirectMergePolicy ) {
1514            foreach ( $this->mResolvedRedirectTitles as $titleFrom ) {
1515                $dest = $this->resolveRedirectTitleDest( $titleFrom );
1516                $fromNs = $titleFrom->getNamespace();
1517                $fromDBkey = $titleFrom->getDBkey();
1518                $toPageId = $dest->getArticleID();
1519                if ( isset( $data[$toPageId] ) &&
1520                    isset( $this->mGeneratorData[$fromNs][$fromDBkey] )
1521                ) {
1522                    // It is necessary to set both $data and add to $result, if an ApiResult,
1523                    // to ensure multiple redirects to the same destination are all merged.
1524                    $data[$toPageId] = ( $this->mRedirectMergePolicy )(
1525                        $data[$toPageId],
1526                        $this->mGeneratorData[$fromNs][$fromDBkey]
1527                    );
1528                    if ( $result instanceof ApiResult &&
1529                        !$result->addValue( $path, $toPageId, $data[$toPageId], ApiResult::OVERRIDE )
1530                    ) {
1531                        return false;
1532                    }
1533                }
1534            }
1535        }
1536
1537        return true;
1538    }
1539
1540    /**
1541     * Get the database connection (read-only)
1542     * @return \Wikimedia\Rdbms\IReadableDatabase
1543     */
1544    protected function getDB() {
1545        return $this->mDbSource->getDB();
1546    }
1547
1548    /** @inheritDoc */
1549    public function getAllowedParams( $flags = 0 ) {
1550        $result = [
1551            'titles' => [
1552                ParamValidator::PARAM_ISMULTI => true,
1553                ApiBase::PARAM_HELP_MSG => 'api-pageset-param-titles',
1554            ],
1555            'pageids' => [
1556                ParamValidator::PARAM_TYPE => 'integer',
1557                ParamValidator::PARAM_ISMULTI => true,
1558                ApiBase::PARAM_HELP_MSG => 'api-pageset-param-pageids',
1559            ],
1560            'revids' => [
1561                ParamValidator::PARAM_TYPE => 'integer',
1562                ParamValidator::PARAM_ISMULTI => true,
1563                ApiBase::PARAM_HELP_MSG => 'api-pageset-param-revids',
1564            ],
1565            'generator' => [
1566                ParamValidator::PARAM_TYPE => null,
1567                ApiBase::PARAM_HELP_MSG => 'api-pageset-param-generator',
1568                SubmoduleDef::PARAM_SUBMODULE_PARAM_PREFIX => 'g',
1569            ],
1570            'redirects' => [
1571                ParamValidator::PARAM_DEFAULT => false,
1572                ApiBase::PARAM_HELP_MSG => $this->mAllowGenerator
1573                    ? 'api-pageset-param-redirects-generator'
1574                    : 'api-pageset-param-redirects-nogenerator',
1575            ],
1576            'converttitles' => [
1577                ParamValidator::PARAM_DEFAULT => false,
1578                ApiBase::PARAM_HELP_MSG => [
1579                    'api-pageset-param-converttitles',
1580                    Message::listParam( LanguageConverter::$languagesWithVariants, ListType::AND ),
1581                ],
1582            ],
1583        ];
1584
1585        if ( !$this->mAllowGenerator ) {
1586            unset( $result['generator'] );
1587        } elseif ( $flags & ApiBase::GET_VALUES_FOR_HELP ) {
1588            $result['generator'][ParamValidator::PARAM_TYPE] = 'submodule';
1589            $result['generator'][SubmoduleDef::PARAM_SUBMODULE_MAP] = $this->getGenerators();
1590        }
1591
1592        return $result;
1593    }
1594
1595    /** @inheritDoc */
1596    public function handleParamNormalization( $paramName, $value, $rawValue ) {
1597        parent::handleParamNormalization( $paramName, $value, $rawValue );
1598
1599        if ( $paramName === 'titles' ) {
1600            // For the 'titles' parameter, we want to split it like ApiBase would
1601            // and add any changed titles to $this->mNormalizedTitles
1602            $value = ParamValidator::explodeMultiValue( $value, self::LIMIT_SML2 + 1 );
1603            $l = count( $value );
1604            $rawValue = ParamValidator::explodeMultiValue( $rawValue, $l );
1605            for ( $i = 0; $i < $l; $i++ ) {
1606                if ( $value[$i] !== $rawValue[$i] ) {
1607                    $this->mNormalizedTitles[$rawValue[$i]] = $value[$i];
1608                }
1609            }
1610        }
1611    }
1612
1613    /**
1614     * Get an array of all available generators
1615     * @return array<string,string>
1616     */
1617    private function getGenerators() {
1618        if ( self::$generators === null ) {
1619            $query = $this->mDbSource;
1620            if ( !( $query instanceof ApiQuery ) ) {
1621                // If the parent container of this pageset is not ApiQuery,
1622                // we must create it to get module manager
1623                $query = $this->getMain()->getModuleManager()->getModule( 'query' );
1624            }
1625            $gens = [];
1626            $prefix = $query->getModulePath() . '+';
1627            $mgr = $query->getModuleManager();
1628            foreach ( $mgr->getNamesWithClasses() as $name => $class ) {
1629                if ( is_subclass_of( $class, ApiQueryGeneratorBase::class ) ) {
1630                    $gens[$name] = $prefix . $name;
1631                }
1632            }
1633            ksort( $gens );
1634            self::$generators = $gens;
1635        }
1636
1637        return self::$generators;
1638    }
1639}
1640
1641/** @deprecated class alias since 1.43 */
1642class_alias( ApiPageSet::class, 'ApiPageSet' );