Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryBase
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 32
5402
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requestExtraData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDB
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPageSet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetQueryParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryBuilder
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addTables
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 addJoinConds
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 addFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addFieldsIf
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 addWhere
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 addWhereIf
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 addWhereFld
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
20
 addWhereIDsFld
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addWhereRange
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 addTimestampWhereRange
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 addOption
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 select
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
90
 processRow
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addTitleInfo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addPageSubItems
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 addPageSubItem
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 setContinueEnumParameter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 titlePartToKey
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 parsePrefixedTitlePart
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 validateSha1Hash
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateSha1Base36Hash
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userCanSeeRevDel
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 executeGenderCacheFromResultWrapper
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\MediaWikiServices;
24use MediaWiki\Title\MalformedTitleException;
25use MediaWiki\Title\Title;
26use MediaWiki\Title\TitleValue;
27use Wikimedia\Rdbms\IDatabase;
28use Wikimedia\Rdbms\IExpression;
29use Wikimedia\Rdbms\IReadableDatabase;
30use Wikimedia\Rdbms\IResultWrapper;
31use Wikimedia\Rdbms\SelectQueryBuilder;
32
33/**
34 * This is a base class for all Query modules.
35 * It provides some common functionality such as constructing various SQL
36 * queries.
37 *
38 * @stable to extend
39 *
40 * @ingroup API
41 */
42abstract class ApiQueryBase extends ApiBase {
43    use ApiQueryBlockInfoTrait;
44
45    private ApiQuery $mQueryModule;
46    private ?IReadableDatabase $mDb;
47
48    /**
49     * @var SelectQueryBuilder|null
50     */
51    private $queryBuilder;
52
53    /**
54     * @stable to call
55     * @param ApiQuery $queryModule
56     * @param string $moduleName
57     * @param string $paramPrefix
58     */
59    public function __construct( ApiQuery $queryModule, $moduleName, $paramPrefix = '' ) {
60        parent::__construct( $queryModule->getMain(), $moduleName, $paramPrefix );
61        $this->mQueryModule = $queryModule;
62        $this->mDb = null;
63        $this->resetQueryParams();
64    }
65
66    /***************************************************************************/
67    // region   Methods to implement
68    /** @name   Methods to implement */
69
70    /**
71     * Get the cache mode for the data generated by this module. Override
72     * this in the module subclass. For possible return values and other
73     * details about cache modes, see ApiMain::setCacheMode()
74     *
75     * Public caching will only be allowed if *all* the modules that supply
76     * data for a given request return a cache mode of public.
77     *
78     * @stable to override
79     * @param array $params
80     * @return string
81     */
82    public function getCacheMode( $params ) {
83        return 'private';
84    }
85
86    /**
87     * Override this method to request extra fields from the pageSet
88     * using $pageSet->requestField('fieldName')
89     *
90     * Note this only makes sense for 'prop' modules, as 'list' and 'meta'
91     * modules should not be using the pageset.
92     *
93     * @stable to override
94     * @param ApiPageSet $pageSet
95     */
96    public function requestExtraData( $pageSet ) {
97    }
98
99    // endregion -- end of methods to implement
100
101    /***************************************************************************/
102    // region   Data access
103    /** @name   Data access */
104
105    /**
106     * Get the main Query module
107     * @return ApiQuery
108     */
109    public function getQuery() {
110        return $this->mQueryModule;
111    }
112
113    /** @inheritDoc */
114    public function getParent() {
115        return $this->getQuery();
116    }
117
118    /**
119     * Get the Query database connection (read-only)
120     * @stable to override
121     * @return IReadableDatabase
122     */
123    protected function getDB() {
124        $this->mDb ??= $this->getQuery()->getDB();
125
126        return $this->mDb;
127    }
128
129    /**
130     * Get the PageSet object to work on
131     * @stable to override
132     * @return ApiPageSet
133     */
134    protected function getPageSet() {
135        return $this->getQuery()->getPageSet();
136    }
137
138    // endregion -- end of data access
139
140    /***************************************************************************/
141    // region   Querying
142    /** @name   Querying */
143
144    /**
145     * Blank the internal arrays with query parameters
146     */
147    protected function resetQueryParams() {
148        $this->queryBuilder = null;
149    }
150
151    /**
152     * Get the SelectQueryBuilder.
153     *
154     * This is lazy initialised since getDB() fails in ApiQueryAllImages if it
155     * is called before the constructor completes.
156     *
157     * @return SelectQueryBuilder
158     */
159    protected function getQueryBuilder() {
160        $this->queryBuilder ??= $this->getDB()->newSelectQueryBuilder();
161        return $this->queryBuilder;
162    }
163
164    /**
165     * Add a set of tables to the internal array
166     * @param string|array $tables Table name or array of table names
167     *  or nested arrays for joins using parentheses for grouping
168     * @param string|null $alias Table alias, or null for no alias. Cannot be
169     *  used with multiple tables
170     */
171    protected function addTables( $tables, $alias = null ) {
172        if ( is_array( $tables ) ) {
173            if ( $alias !== null ) {
174                ApiBase::dieDebug( __METHOD__, 'Multiple table aliases not supported' );
175            }
176            $this->getQueryBuilder()->rawTables( $tables );
177        } else {
178            $this->getQueryBuilder()->table( $tables, $alias );
179        }
180    }
181
182    /**
183     * Add a set of JOIN conditions to the internal array
184     *
185     * JOIN conditions are formatted as [ tablename => [ jointype, conditions ] ]
186     * e.g. [ 'page' => [ 'LEFT JOIN', 'page_id=rev_page' ] ].
187     * Conditions may be a string or an addWhere()-style array.
188     * @param array $join_conds JOIN conditions
189     */
190    protected function addJoinConds( $join_conds ) {
191        if ( !is_array( $join_conds ) ) {
192            ApiBase::dieDebug( __METHOD__, 'Join conditions have to be arrays' );
193        }
194        $this->getQueryBuilder()->joinConds( $join_conds );
195    }
196
197    /**
198     * Add a set of fields to select to the internal array
199     * @param array|string $value Field name or array of field names
200     */
201    protected function addFields( $value ) {
202        $this->getQueryBuilder()->fields( $value );
203    }
204
205    /**
206     * Same as addFields(), but add the fields only if a condition is met
207     * @param array|string $value See addFields()
208     * @param bool $condition If false, do nothing
209     * @return bool
210     */
211    protected function addFieldsIf( $value, $condition ) {
212        if ( $condition ) {
213            $this->addFields( $value );
214
215            return true;
216        }
217
218        return false;
219    }
220
221    /**
222     * Add a set of WHERE clauses to the internal array.
223     *
224     * The array should be appropriate for passing as $conds to
225     * IDatabase::select(). Arrays from multiple calls are merged with
226     * array_merge(). A string is treated as a single-element array.
227     *
228     * When passing `'field' => $arrayOfIDs` where the IDs are taken from user
229     * input, consider using addWhereIDsFld() instead.
230     *
231     * @see IDatabase::select()
232     * @param string|array|IExpression $value
233     */
234    protected function addWhere( $value ) {
235        if ( is_array( $value ) ) {
236            // Double check: don't insert empty arrays,
237            // Database::makeList() chokes on them
238            if ( count( $value ) ) {
239                $this->getQueryBuilder()->where( $value );
240            }
241        } else {
242            $this->getQueryBuilder()->where( $value );
243        }
244    }
245
246    /**
247     * Same as addWhere(), but add the WHERE clauses only if a condition is met
248     * @param string|array|IExpression $value
249     * @param bool $condition If false, do nothing
250     * @return bool
251     */
252    protected function addWhereIf( $value, $condition ) {
253        if ( $condition ) {
254            $this->addWhere( $value );
255
256            return true;
257        }
258
259        return false;
260    }
261
262    /**
263     * Equivalent to addWhere( [ $field => $value ] )
264     *
265     * When $value is an array of integer IDs taken from user input,
266     * consider using addWhereIDsFld() instead.
267     *
268     * @param string $field Field name
269     * @param int|string|(string|int|null)[] $value Value; ignored if null or empty array
270     */
271    protected function addWhereFld( $field, $value ) {
272        if ( $value !== null && !( is_array( $value ) && !$value ) ) {
273            $this->getQueryBuilder()->where( [ $field => $value ] );
274        }
275    }
276
277    /**
278     * Like addWhereFld for an integer list of IDs
279     *
280     * When passed wildly out-of-range values for integer comparison,
281     * the database may choose a poor query plan. This method validates the
282     * passed IDs against the range of values in the database to omit
283     * out-of-range values.
284     *
285     * This should be used when the IDs are derived from arbitrary user input;
286     * it is not necessary if the IDs are already known to be within a sensible
287     * range.
288     *
289     * This should not be used when there is not a suitable index on $field to
290     * quickly retrieve the minimum and maximum values.
291     *
292     * @since 1.33
293     * @param string $table Table name
294     * @param string $field Field name
295     * @param int[] $ids
296     * @return int Count of IDs actually included
297     */
298    protected function addWhereIDsFld( $table, $field, $ids ) {
299        // Use count() to its full documented capabilities to simultaneously
300        // test for null, empty array or empty countable object
301        if ( count( $ids ) ) {
302            $ids = $this->filterIDs( [ [ $table, $field ] ], $ids );
303
304            if ( $ids === [] ) {
305                // Return nothing, no IDs are valid
306                $this->getQueryBuilder()->where( '0 = 1' );
307            } else {
308                $this->getQueryBuilder()->where( [ $field => $ids ] );
309            }
310        }
311        return count( $ids );
312    }
313
314    /**
315     * Add a WHERE clause corresponding to a range, and an ORDER BY
316     * clause to sort in the right direction
317     * @param string $field Field name
318     * @param string $dir If 'newer', sort in ascending order, otherwise
319     *  sort in descending order
320     * @param string|int|null $start Value to start the list at. If $dir == 'newer'
321     *  this is the lower boundary, otherwise it's the upper boundary
322     * @param string|int|null $end Value to end the list at. If $dir == 'newer' this
323     *  is the upper boundary, otherwise it's the lower boundary
324     * @param bool $sort If false, don't add an ORDER BY clause
325     */
326    protected function addWhereRange( $field, $dir, $start, $end, $sort = true ) {
327        $isDirNewer = ( $dir === 'newer' );
328        $after = ( $isDirNewer ? '>=' : '<=' );
329        $before = ( $isDirNewer ? '<=' : '>=' );
330        $db = $this->getDB();
331
332        if ( $start !== null ) {
333            $this->addWhere( $db->expr( $field, $after, $start ) );
334        }
335
336        if ( $end !== null ) {
337            $this->addWhere( $db->expr( $field, $before, $end ) );
338        }
339
340        if ( $sort ) {
341            $this->getQueryBuilder()->orderBy( $field, $isDirNewer ? null : 'DESC' );
342        }
343    }
344
345    /**
346     * Add a WHERE clause corresponding to a range, similar to addWhereRange,
347     * but converts $start and $end to database timestamps.
348     * @see addWhereRange
349     * @param string $field
350     * @param string $dir
351     * @param string|int|null $start
352     * @param string|int|null $end
353     * @param bool $sort
354     */
355    protected function addTimestampWhereRange( $field, $dir, $start, $end, $sort = true ) {
356        $db = $this->getDB();
357        $this->addWhereRange( $field, $dir,
358            $db->timestampOrNull( $start ), $db->timestampOrNull( $end ), $sort );
359    }
360
361    /**
362     * Add an option such as LIMIT or USE INDEX. If an option was set
363     * before, the old value will be overwritten
364     * @param string $name Option name
365     * @param mixed $value The option value, or null for a boolean option
366     */
367    protected function addOption( $name, $value = null ) {
368        $this->getQueryBuilder()->option( $name, $value );
369    }
370
371    /**
372     * Execute a SELECT query based on the values in the internal arrays
373     * @param string $method Function the query should be attributed to.
374     *  You should usually use __METHOD__ here
375     * @param array $extraQuery Query data to add but not store in the object
376     *  Format is [
377     *    'tables' => ...,
378     *    'fields' => ...,
379     *    'where' => ...,
380     *    'options' => ...,
381     *    'join_conds' => ...
382     *  ]
383     * @param array|null &$hookData If set, the ApiQueryBaseBeforeQuery and
384     *  ApiQueryBaseAfterQuery hooks will be called, and the
385     *  ApiQueryBaseProcessRow hook will be expected.
386     * @return IResultWrapper
387     */
388    protected function select( $method, $extraQuery = [], array &$hookData = null ) {
389        $queryBuilder = clone $this->getQueryBuilder();
390        if ( isset( $extraQuery['tables'] ) ) {
391            $queryBuilder->rawTables( (array)$extraQuery['tables'] );
392        }
393        if ( isset( $extraQuery['fields'] ) ) {
394            $queryBuilder->fields( (array)$extraQuery['fields'] );
395        }
396        if ( isset( $extraQuery['where'] ) ) {
397            $queryBuilder->where( (array)$extraQuery['where'] );
398        }
399        if ( isset( $extraQuery['options'] ) ) {
400            $queryBuilder->options( (array)$extraQuery['options'] );
401        }
402        if ( isset( $extraQuery['join_conds'] ) ) {
403            $queryBuilder->joinConds( (array)$extraQuery['join_conds'] );
404        }
405
406        if ( $hookData !== null && $this->getHookContainer()->isRegistered( 'ApiQueryBaseBeforeQuery' ) ) {
407            $info = $queryBuilder->getQueryInfo();
408            $this->getHookRunner()->onApiQueryBaseBeforeQuery(
409                $this, $info['tables'], $info['fields'], $info['conds'],
410                $info['options'], $info['join_conds'], $hookData
411            );
412            $queryBuilder = $this->getDB()->newSelectQueryBuilder()->queryInfo( $info );
413        }
414
415        $queryBuilder->caller( $method );
416        $res = $queryBuilder->fetchResultSet();
417
418        if ( $hookData !== null ) {
419            $this->getHookRunner()->onApiQueryBaseAfterQuery( $this, $res, $hookData );
420        }
421
422        return $res;
423    }
424
425    /**
426     * Call the ApiQueryBaseProcessRow hook
427     *
428     * Generally, a module that passed $hookData to self::select() will call
429     * this just before calling ApiResult::addValue(), and treat a false return
430     * here in the same way it treats a false return from addValue().
431     *
432     * @since 1.28
433     * @param stdClass $row Database row
434     * @param array &$data Data to be added to the result
435     * @param array &$hookData Hook data from ApiQueryBase::select() @phan-output-reference
436     * @return bool Return false if row processing should end with continuation
437     */
438    protected function processRow( $row, array &$data, array &$hookData ) {
439        return $this->getHookRunner()->onApiQueryBaseProcessRow( $this, $row, $data, $hookData );
440    }
441
442    // endregion -- end of querying
443
444    /***************************************************************************/
445    // region   Utility methods
446    /** @name   Utility methods */
447
448    /**
449     * Add information (title and namespace) about a Title object to a
450     * result array
451     * @param array &$arr Result array à la ApiResult
452     * @param Title $title
453     * @param string $prefix Module prefix
454     */
455    public static function addTitleInfo( &$arr, $title, $prefix = '' ) {
456        $arr[$prefix . 'ns'] = $title->getNamespace();
457        $arr[$prefix . 'title'] = $title->getPrefixedText();
458    }
459
460    /**
461     * Add a sub-element under the page element with the given page ID
462     * @param int $pageId
463     * @param array $data Data array à la ApiResult
464     * @return bool Whether the element fit in the result
465     */
466    protected function addPageSubItems( $pageId, $data ) {
467        $result = $this->getResult();
468        ApiResult::setIndexedTagName( $data, $this->getModulePrefix() );
469
470        return $result->addValue( [ 'query', 'pages', (int)$pageId ],
471            $this->getModuleName(),
472            $data );
473    }
474
475    /**
476     * Same as addPageSubItems(), but one element of $data at a time
477     * @param int $pageId
478     * @param mixed $item Data à la ApiResult
479     * @param string|null $elemname XML element name. If null, getModuleName()
480     *  is used
481     * @return bool Whether the element fit in the result
482     */
483    protected function addPageSubItem( $pageId, $item, $elemname = null ) {
484        $result = $this->getResult();
485        $fit = $result->addValue( [ 'query', 'pages', $pageId,
486            $this->getModuleName() ], null, $item );
487        if ( !$fit ) {
488            return false;
489        }
490        $result->addIndexedTagName(
491            [ 'query', 'pages', $pageId, $this->getModuleName() ],
492            $elemname ?? $this->getModulePrefix()
493        );
494
495        return true;
496    }
497
498    /**
499     * Set a query-continue value
500     * @param string $paramName Parameter name
501     * @param int|string|array $paramValue Parameter value
502     */
503    protected function setContinueEnumParameter( $paramName, $paramValue ) {
504        $this->getContinuationManager()->addContinueParam( $this, $paramName, $paramValue );
505    }
506
507    /**
508     * Convert an input title or title prefix into a dbkey.
509     *
510     * $namespace should always be specified in order to handle per-namespace
511     * capitalization settings.
512     *
513     * @param string $titlePart
514     * @param int $namespace Namespace of the title
515     * @return string DBkey (no namespace prefix)
516     */
517    public function titlePartToKey( $titlePart, $namespace = NS_MAIN ) {
518        $t = Title::makeTitleSafe( $namespace, $titlePart . 'x' );
519        if ( !$t || $t->hasFragment() ) {
520            // Invalid title (e.g. bad chars) or contained a '#'.
521            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $titlePart ) ] );
522        }
523        if ( $namespace != $t->getNamespace() || $t->isExternal() ) {
524            // This can happen in two cases. First, if you call titlePartToKey with a title part
525            // that looks like a namespace, but with $defaultNamespace = NS_MAIN. It would be very
526            // difficult to handle such a case. Such cases cannot exist and are therefore treated
527            // as invalid user input. The second case is when somebody specifies a title interwiki
528            // prefix.
529            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $titlePart ) ] );
530        }
531
532        return substr( $t->getDBkey(), 0, -1 );
533    }
534
535    /**
536     * Convert an input title or title prefix into a TitleValue.
537     *
538     * @since 1.35
539     * @param string $titlePart
540     * @param int $defaultNamespace Default namespace if none is given
541     * @return TitleValue
542     */
543    protected function parsePrefixedTitlePart( $titlePart, $defaultNamespace = NS_MAIN ) {
544        try {
545            $titleParser = MediaWikiServices::getInstance()->getTitleParser();
546            $t = $titleParser->parseTitle( $titlePart . 'X', $defaultNamespace );
547        } catch ( MalformedTitleException $e ) {
548            $t = null;
549        }
550
551        if ( !$t || $t->hasFragment() || $t->isExternal() || $t->getDBkey() === 'X' ) {
552            // Invalid title (e.g. bad chars) or contained a '#'.
553            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $titlePart ) ] );
554        }
555
556        return new TitleValue( $t->getNamespace(), substr( $t->getDBkey(), 0, -1 ) );
557    }
558
559    /**
560     * @param string $hash
561     * @return bool
562     */
563    public function validateSha1Hash( $hash ) {
564        return (bool)preg_match( '/^[a-f0-9]{40}$/', $hash );
565    }
566
567    /**
568     * @param string $hash
569     * @return bool
570     */
571    public function validateSha1Base36Hash( $hash ) {
572        return (bool)preg_match( '/^[a-z0-9]{31}$/', $hash );
573    }
574
575    /**
576     * Check whether the current user has permission to view revision-deleted
577     * fields.
578     * @return bool
579     */
580    public function userCanSeeRevDel() {
581        return $this->getAuthority()->isAllowedAny(
582            'deletedhistory',
583            'deletedtext',
584            'deleterevision',
585            'suppressrevision',
586            'viewsuppressed'
587        );
588    }
589
590    /**
591     * Preprocess the result set to fill the GenderCache with the necessary information
592     * before using self::addTitleInfo
593     *
594     * @param IResultWrapper $res Result set to work on.
595     *  The result set must have _namespace and _title fields with the provided field prefix
596     * @param string $fname The caller function name, always use __METHOD__
597     * @param string $fieldPrefix Prefix for fields to check gender for
598     */
599    protected function executeGenderCacheFromResultWrapper(
600        IResultWrapper $res, $fname = __METHOD__, $fieldPrefix = 'page'
601    ) {
602        if ( !$res->numRows() ) {
603            return;
604        }
605
606        $services = MediaWikiServices::getInstance();
607        if ( !$services->getContentLanguage()->needsGenderDistinction() ) {
608            return;
609        }
610
611        $nsInfo = $services->getNamespaceInfo();
612        $namespaceField = $fieldPrefix . '_namespace';
613        $titleField = $fieldPrefix . '_title';
614
615        $usernames = [];
616        foreach ( $res as $row ) {
617            if ( $nsInfo->hasGenderDistinction( $row->$namespaceField ) ) {
618                $usernames[] = $row->$titleField;
619            }
620        }
621
622        if ( $usernames === [] ) {
623            return;
624        }
625
626        $genderCache = $services->getGenderCache();
627        $genderCache->doQuery( $usernames, $fname );
628    }
629
630    // endregion -- end of utility methods
631}