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