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