MediaWiki master
ApiQueryRevisions.php
Go to the documentation of this file.
1<?php
9namespace MediaWiki\Api;
10
31
41
42 public function __construct(
43 ApiQuery $query,
44 string $moduleName,
45 private readonly RevisionStore $revisionStore,
46 IContentHandlerFactory $contentHandlerFactory,
47 ParserFactory $parserFactory,
48 SlotRoleRegistry $slotRoleRegistry,
49 private readonly NameTableStore $changeTagDefStore,
50 private readonly ChangeTagsStore $changeTagsStore,
51 private readonly ActorMigration $actorMigration,
52 ContentRenderer $contentRenderer,
53 ContentTransformer $contentTransformer,
54 CommentFormatter $commentFormatter,
55 TempUserCreator $tempUserCreator,
56 UserFactory $userFactory,
57 private readonly TitleFormatter $titleFormatter,
58 ) {
59 parent::__construct(
60 $query,
61 $moduleName,
62 'rv',
63 $revisionStore,
64 $contentHandlerFactory,
65 $parserFactory,
66 $slotRoleRegistry,
67 $contentRenderer,
68 $contentTransformer,
69 $commentFormatter,
70 $tempUserCreator,
71 $userFactory
72 );
73 }
74
75 protected function run( ?ApiPageSet $resultPageSet = null ) {
76 $params = $this->extractRequestParams( false );
77
78 // If any of those parameters are used, work in 'enumeration' mode.
79 // Enum mode can only be used when exactly one page is provided.
80 // Enumerating revisions on multiple pages make it extremely
81 // difficult to manage continuations and require additional SQL indexes
82 $enumRevMode = ( $params['user'] !== null || $params['excludeuser'] !== null ||
83 $params['limit'] !== null || $params['startid'] !== null ||
84 $params['endid'] !== null || $params['dir'] === 'newer' ||
85 $params['start'] !== null || $params['end'] !== null );
86
87 $pageSet = $this->getPageSet();
88 $pageCount = $pageSet->getGoodTitleCount();
89 $revCount = $pageSet->getRevisionCount();
90
91 // Optimization -- nothing to do
92 if ( $revCount === 0 && $pageCount === 0 ) {
93 // Nothing to do
94 return;
95 }
96 if ( $revCount > 0 && count( $pageSet->getLiveRevisionIDs() ) === 0 ) {
97 // We're in revisions mode but all given revisions are deleted
98 return;
99 }
100
101 if ( $revCount > 0 && $enumRevMode ) {
102 $this->dieWithError(
103 [ 'apierror-revisions-norevids', $this->getModulePrefix() ], 'invalidparammix'
104 );
105 }
106
107 if ( $pageCount > 1 && $enumRevMode ) {
108 $this->dieWithError(
109 [ 'apierror-revisions-singlepage', $this->getModulePrefix() ], 'invalidparammix'
110 );
111 }
112
113 // In non-enum mode, rvlimit can't be directly used. Use the maximum
114 // allowed value.
115 if ( !$enumRevMode ) {
116 $this->setParsedLimit = false;
117 $params['limit'] = 'max';
118 }
119
120 $db = $this->getDB();
121
122 $idField = 'rev_id';
123 $tsField = 'rev_timestamp';
124 $pageField = 'rev_page';
125
126 $ignoreIndex = [
127 // T224017: `rev_timestamp` is never the correct index to use for this module, but
128 // MariaDB sometimes insists on trying to use it anyway. Tell it not to.
129 // Last checked with MariaDB 10.4.13
130 'revision' => 'rev_timestamp',
131 ];
132 $useIndex = [];
133 if ( $resultPageSet === null ) {
134 $this->parseParameters( $params );
135 $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $db )
136 ->joinComment()
137 ->joinPage();
138 if ( $this->fld_user ) {
139 $queryBuilder->joinUser();
140 }
141 $this->getQueryBuilder()->merge( $queryBuilder );
142 } else {
143 $this->limit = $this->getParameter( 'limit' ) ?: 10;
144 // Always join 'page' so orphaned revisions are filtered out
145 $this->addTables( [ 'revision', 'page' ] );
146 $this->addJoinConds(
147 [ 'page' => [ 'JOIN', [ 'page_id = rev_page' ] ] ]
148 );
149 $this->addFields( [
150 'rev_id' => $idField, 'rev_timestamp' => $tsField, 'rev_page' => $pageField
151 ] );
152 }
153
154 if ( $this->fld_tags ) {
155 $this->addFields( [
156 'ts_tags' => $this->changeTagsStore->makeTagSummarySubquery( 'revision', $this->getAuthority() )
157 ] );
158 }
159
160 if ( $params['tag'] !== null ) {
161 if ( !$this->changeTagsStore->canViewTag( $params['tag'], $this->getAuthority() ) ) {
162 $this->addWhere( '1=0' );
163 }
164
165 $this->addTables( 'change_tag' );
166 $this->addJoinConds(
167 [ 'change_tag' => [ 'JOIN', [ 'rev_id=ct_rev_id' ] ] ]
168 );
169 try {
170 $this->addWhereFld( 'ct_tag_id', $this->changeTagDefStore->getId( $params['tag'] ) );
171 } catch ( NameTableAccessException ) {
172 // Return nothing.
173 $this->addWhere( '1=0' );
174 }
175 }
176
177 if ( $resultPageSet === null && $this->fetchContent ) {
178 // For each page we will request, the user must have read rights for that page
179 $status = Status::newGood();
180
182 foreach ( $pageSet->getGoodPages() as $pageIdentity ) {
183 if ( !$this->getAuthority()->authorizeRead( 'read', $pageIdentity ) ) {
184 $status->fatal( ApiMessage::create(
185 [
186 'apierror-cannotviewtitle',
187 wfEscapeWikiText( $this->titleFormatter->getPrefixedText( $pageIdentity ) ),
188 ],
189 'accessdenied'
190 ) );
191 }
192 }
193 if ( !$status->isGood() ) {
194 $this->dieStatus( $status );
195 }
196 }
197
198 if ( $enumRevMode ) {
199 // Indexes targeted:
200 // page_timestamp if we don't have rvuser
201 // page_actor_timestamp (on revision_actor_temp) if we have rvuser in READ_NEW mode
202 // page_user_timestamp if we have a logged-in rvuser
203 // page_timestamp or usertext_timestamp if we have an IP rvuser
204
205 // This is mostly to prevent parameter errors (and optimize SQL?)
206 $this->requireMaxOneParameter( $params, 'startid', 'start' );
207 $this->requireMaxOneParameter( $params, 'endid', 'end' );
208 $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
209
210 if ( $params['continue'] !== null ) {
211 $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'timestamp', 'int' ] );
212 $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
213 $continueTimestamp = $db->timestamp( $cont[0] );
214 $continueId = (int)$cont[1];
215 $this->addWhere( $db->buildComparison( $op, [
216 $tsField => $continueTimestamp,
217 $idField => $continueId,
218 ] ) );
219 }
220
221 // Convert startid/endid to timestamps (T163532)
222 $revids = [];
223 if ( $params['startid'] !== null ) {
224 $revids[] = (int)$params['startid'];
225 }
226 if ( $params['endid'] !== null ) {
227 $revids[] = (int)$params['endid'];
228 }
229 if ( $revids ) {
230 $db = $this->getDB();
231 $uqb = $db->newUnionQueryBuilder();
232 $uqb->add(
233 $db->newSelectQueryBuilder()
234 ->select( [ 'id' => 'rev_id', 'ts' => 'rev_timestamp' ] )
235 ->from( 'revision' )
236 ->where( [ 'rev_id' => $revids ] )
237 );
238 $uqb->add(
239 $db->newSelectQueryBuilder()
240 ->select( [ 'id' => 'ar_rev_id', 'ts' => 'ar_timestamp' ] )
241 ->from( 'archive' )
242 ->where( [ 'ar_rev_id' => $revids ] )
243 );
244 $res = $uqb->caller( __METHOD__ )->fetchResultSet();
245 foreach ( $res as $row ) {
246 if ( (int)$row->id === (int)$params['startid'] ) {
247 $params['start'] = $row->ts;
248 }
249 if ( (int)$row->id === (int)$params['endid'] ) {
250 $params['end'] = $row->ts;
251 }
252 }
253 if ( $params['startid'] !== null && $params['start'] === null ) {
254 $p = $this->encodeParamName( 'startid' );
255 $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
256 }
257 if ( $params['endid'] !== null && $params['end'] === null ) {
258 $p = $this->encodeParamName( 'endid' );
259 $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
260 }
261
262 if ( $params['start'] !== null ) {
263 $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
264 $ts = $db->timestampOrNull( $params['start'] );
265 if ( $params['startid'] !== null ) {
266 $this->addWhere( $db->buildComparison( $op, [
267 $tsField => $ts,
268 $idField => (int)$params['startid'],
269 ] ) );
270 } else {
271 $this->addWhere( $db->buildComparison( $op, [ $tsField => $ts ] ) );
272 }
273 }
274 if ( $params['end'] !== null ) {
275 $op = ( $params['dir'] === 'newer' ? '<=' : '>=' ); // Yes, opposite of the above
276 $ts = $db->timestampOrNull( $params['end'] );
277 if ( $params['endid'] !== null ) {
278 $this->addWhere( $db->buildComparison( $op, [
279 $tsField => $ts,
280 $idField => (int)$params['endid'],
281 ] ) );
282 } else {
283 $this->addWhere( $db->buildComparison( $op, [ $tsField => $ts ] ) );
284 }
285 }
286 } else {
287 $this->addTimestampWhereRange( $tsField, $params['dir'],
288 $params['start'], $params['end'] );
289 }
290
291 $sort = ( $params['dir'] === 'newer' ? '' : 'DESC' );
292 $this->addOption( 'ORDER BY', [ "rev_timestamp $sort", "rev_id $sort" ] );
293
294 // There is only one ID, use it
295 $ids = array_keys( $pageSet->getGoodPages() );
296 $this->addWhereFld( $pageField, reset( $ids ) );
297
298 if ( $params['user'] !== null ) {
299 $actorQuery = $this->actorMigration->getWhere( $db, 'rev_user', $params['user'] );
300 $this->addTables( $actorQuery['tables'] );
301 $this->addJoinConds( $actorQuery['joins'] );
302 $this->addWhere( $actorQuery['conds'] );
303 } elseif ( $params['excludeuser'] !== null ) {
304 $actorQuery = $this->actorMigration->getWhere( $db, 'rev_user', $params['excludeuser'] );
305 $this->addTables( $actorQuery['tables'] );
306 $this->addJoinConds( $actorQuery['joins'] );
307 $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' );
308 } else {
309 // T258480: MariaDB ends up using rev_page_actor_timestamp in some cases here.
310 // Last checked with MariaDB 10.4.13
311 // Unless we are filtering by user (see above), we always want to use the
312 // "history" index on the revision table, namely page_timestamp.
313 $useIndex['revision'] = 'rev_page_timestamp';
314 }
315
316 if ( $params['user'] !== null || $params['excludeuser'] !== null ) {
317 // Paranoia: avoid brute force searches (T19342)
318 if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
319 $bitmask = RevisionRecord::DELETED_USER;
320 } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
321 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
322 } else {
323 $bitmask = 0;
324 }
325 if ( $bitmask ) {
326 $this->addWhere( $db->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" );
327 }
328 }
329 } elseif ( $revCount > 0 ) {
330 // Always targets the PRIMARY index
331
332 $revs = $pageSet->getLiveRevisionIDs();
333
334 // Get all revision IDs
335 $this->addWhereFld( 'rev_id', array_keys( $revs ) );
336
337 if ( $params['continue'] !== null ) {
338 $this->addWhere( $db->buildComparison( '>=', [
339 'rev_id' => (int)$params['continue']
340 ] ) );
341 }
342 $this->addOption( 'ORDER BY', 'rev_id' );
343 } elseif ( $pageCount > 0 ) {
344 // Always targets the rev_page_id index
345
346 $pageids = array_keys( $pageSet->getGoodPages() );
347
348 // When working in multi-page non-enumeration mode,
349 // limit to the latest revision only
350 $this->addWhere( 'page_latest=rev_id' );
351
352 // Get all page IDs
353 $this->addWhereFld( 'page_id', $pageids );
354 // Every time someone relies on equality propagation, god kills a kitten :)
355 $this->addWhereFld( 'rev_page', $pageids );
356
357 if ( $params['continue'] !== null ) {
358 $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'int', 'int' ] );
359 $this->addWhere( $db->buildComparison( '>=', [
360 'rev_page' => $cont[0],
361 'rev_id' => $cont[1],
362 ] ) );
363 }
364 $this->addOption( 'ORDER BY', [
365 'rev_page',
366 'rev_id'
367 ] );
368 } else {
369 ApiBase::dieDebug( __METHOD__, 'param validation?' );
370 }
371
372 $this->addOption( 'LIMIT', $this->limit + 1 );
373
374 $this->addOption( 'IGNORE INDEX', $ignoreIndex );
375
376 if ( $useIndex ) {
377 $this->addOption( 'USE INDEX', $useIndex );
378 }
379
380 $count = 0;
381 $generated = [];
382 $hookData = [];
383 $res = $this->select( __METHOD__, [], $hookData );
384
385 foreach ( $res as $row ) {
386 if ( ++$count > $this->limit ) {
387 // We've reached the one extra which shows that there are
388 // additional pages to be had. Stop here...
389 if ( $enumRevMode ) {
390 $this->setContinueEnumParameter( 'continue',
391 $row->rev_timestamp . '|' . (int)$row->rev_id );
392 } elseif ( $revCount > 0 ) {
393 $this->setContinueEnumParameter( 'continue', (int)$row->rev_id );
394 } else {
395 $this->setContinueEnumParameter( 'continue', (int)$row->rev_page .
396 '|' . (int)$row->rev_id );
397 }
398 break;
399 }
400
401 if ( $resultPageSet !== null ) {
402 $generated[] = $row->rev_id;
403 } else {
404 $revision = $this->revisionStore->newRevisionFromRow( $row, 0, Title::newFromRow( $row ) );
405 $rev = $this->extractRevisionInfo( $revision, $row );
406 $fit = $this->processRow( $row, $rev, $hookData ) &&
407 $this->addPageSubItem( $row->rev_page, $rev, 'rev' );
408 if ( !$fit ) {
409 if ( $enumRevMode ) {
410 $this->setContinueEnumParameter( 'continue',
411 $row->rev_timestamp . '|' . (int)$row->rev_id );
412 } elseif ( $revCount > 0 ) {
413 $this->setContinueEnumParameter( 'continue', (int)$row->rev_id );
414 } else {
415 $this->setContinueEnumParameter( 'continue', (int)$row->rev_page .
416 '|' . (int)$row->rev_id );
417 }
418 break;
419 }
420 }
421 }
422
423 if ( $resultPageSet !== null ) {
424 $resultPageSet->populateFromRevisionIDs( $generated );
425 }
426 }
427
429 public function getAllowedParams() {
430 $ret = parent::getAllowedParams() + [
431 'startid' => [
432 ParamValidator::PARAM_TYPE => 'integer',
433 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
434 ],
435 'endid' => [
436 ParamValidator::PARAM_TYPE => 'integer',
437 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
438 ],
439 'start' => [
440 ParamValidator::PARAM_TYPE => 'timestamp',
441 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
442 ],
443 'end' => [
444 ParamValidator::PARAM_TYPE => 'timestamp',
445 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
446 ],
447 'dir' => [
448 ParamValidator::PARAM_DEFAULT => 'older',
449 ParamValidator::PARAM_TYPE => [
450 'newer',
451 'older'
452 ],
453 ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
455 'newer' => 'api-help-paramvalue-direction-newer',
456 'older' => 'api-help-paramvalue-direction-older',
457 ],
458 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
459 ],
460 'user' => [
461 ParamValidator::PARAM_TYPE => 'user',
462 UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
463 UserDef::PARAM_RETURN_OBJECT => true,
464 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
465 ],
466 'excludeuser' => [
467 ParamValidator::PARAM_TYPE => 'user',
468 UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
469 UserDef::PARAM_RETURN_OBJECT => true,
470 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
471 ],
472 'tag' => null,
473 'continue' => [
474 ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
475 ],
476 ];
477
478 $ret['limit'][ApiBase::PARAM_HELP_MSG_INFO] = [ [ 'singlepageonly' ] ];
479
480 return $ret;
481 }
482
484 protected function getExamplesMessages() {
485 $title = Title::newMainPage()->getPrefixedText();
486 $mp = rawurlencode( $title );
487
488 return [
489 "action=query&prop=revisions&titles=API|{$mp}&" .
490 'rvslots=*&rvprop=timestamp|user|comment|content'
491 => 'apihelp-query+revisions-example-content',
492 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
493 'rvprop=timestamp|user|comment'
494 => 'apihelp-query+revisions-example-last5',
495 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
496 'rvprop=timestamp|user|comment&rvdir=newer'
497 => 'apihelp-query+revisions-example-first5',
498 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
499 'rvprop=timestamp|user|comment&rvdir=newer&rvstart=2006-05-01T00:00:00Z'
500 => 'apihelp-query+revisions-example-first5-after',
501 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
502 'rvprop=timestamp|user|comment&rvexcludeuser=127.0.0.1'
503 => 'apihelp-query+revisions-example-first5-not-localhost',
504 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
505 'rvprop=timestamp|user|comment&rvuser=MediaWiki%20default'
506 => 'apihelp-query+revisions-example-first5-user',
507 ];
508 }
509
511 public function getHelpUrls() {
512 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Revisions';
513 }
514}
515
517class_alias( ApiQueryRevisions::class, 'ApiQueryRevisions' );
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1522
getModulePrefix()
Get parameter prefix (usually two letters or an empty string).
Definition ApiBase.php:566
const PARAM_HELP_MSG_INFO
(array) Specify additional information tags for the parameter.
Definition ApiBase.php:184
parseContinueParamOrDie(string $continue, array $types)
Parse the 'continue' parameter in the usual format and validate the types of each part,...
Definition ApiBase.php:1707
const PARAM_HELP_MSG_PER_VALUE
((string|array|Message)[]) When PARAM_TYPE is an array, or 'string' with PARAM_ISMULTI,...
Definition ApiBase.php:206
requireMaxOneParameter( $params,... $required)
Dies if more than one parameter from a certain set of parameters are set and not false.
Definition ApiBase.php:1012
static dieDebug( $method, $message)
Internal code errors should be reported with this method.
Definition ApiBase.php:1759
const PARAM_HELP_MSG
(string|array|Message) Specify an alternative i18n documentation message for this parameter.
Definition ApiBase.php:166
dieStatus(StatusValue $status)
Throw an ApiUsageException based on the Status object.
Definition ApiBase.php:1573
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition ApiBase.php:837
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition ApiBase.php:958
static create( $msg, $code=null, ?array $data=null)
Create an IApiMessage for the message.
This class contains a list of pages that the client has requested.
addOption( $name, $value=null)
Add an option such as LIMIT or USE INDEX.
addPageSubItem( $pageId, $item, $elemname=null)
Same as addPageSubItems(), but one element of $data at a time.
select( $method, $extraQuery=[], ?array &$hookData=null)
Execute a SELECT query based on the values in the internal arrays.
addTimestampWhereRange( $field, $dir, $start, $end, $sort=true)
Add a WHERE clause corresponding to a range, similar to addWhereRange, but converts $start and $end t...
processRow( $row, array &$data, array &$hookData)
Call the ApiQueryBaseProcessRow hook.
addWhereFld( $field, $value)
Equivalent to addWhere( [ $field => $value ] )
setContinueEnumParameter( $paramName, $paramValue)
Overridden to set the generator param if in generator mode.
getPageSet()
Get the PageSet object to work on.
encodeParamName( $paramName)
Overrides ApiBase to prepend 'g' to every generator parameter.
A base class for functions common to producing a list of revisions.
extractRevisionInfo(RevisionRecord $revision, $row)
Extract information from the RevisionRecord.
parseParameters( $params)
Parse the parameters into the various instance fields.
A query action to enumerate revisions of a given page, or show top revisions of multiple pages.
__construct(ApiQuery $query, string $moduleName, private readonly RevisionStore $revisionStore, IContentHandlerFactory $contentHandlerFactory, ParserFactory $parserFactory, SlotRoleRegistry $slotRoleRegistry, private readonly NameTableStore $changeTagDefStore, private readonly ChangeTagsStore $changeTagsStore, private readonly ActorMigration $actorMigration, ContentRenderer $contentRenderer, ContentTransformer $contentTransformer, CommentFormatter $commentFormatter, TempUserCreator $tempUserCreator, UserFactory $userFactory, private readonly TitleFormatter $titleFormatter,)
getExamplesMessages()
Returns usage examples for this module.Return value has query strings as keys, with values being eith...
getHelpUrls()
Return links to more detailed help pages about the module.1.25, returning boolean false is deprecated...
run(?ApiPageSet $resultPageSet=null)
This is the main query class.
Definition ApiQuery.php:36
Read-write access to the change_tags table.
This is the main service interface for converting single-line comments from various DB comment fields...
Type definition for user types.
Definition UserDef.php:27
Page revision base class.
Service for looking up page revisions.
A registry service for SlotRoleHandlers, used to define which slot roles are available on which page.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Exception representing a failure to look up a row from a name table.
A title formatter service for MediaWiki.
Represents a title within MediaWiki.
Definition Title.php:69
This is not intended to be a long-term part of MediaWiki; it will be deprecated and removed once acto...
Service for temporary user creation.
Create User objects.
Service for formatting and validating API parameters.
Interface for objects (potentially) representing an editable wiki page.
addTables( $tables, $alias=null)
addWhere( $conds)
addJoinConds( $conds)
addFields( $fields)