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' )
157 ] );
158 }
159
160 if ( $params['tag'] !== null ) {
161 $this->addTables( 'change_tag' );
162 $this->addJoinConds(
163 [ 'change_tag' => [ 'JOIN', [ 'rev_id=ct_rev_id' ] ] ]
164 );
165 try {
166 $this->addWhereFld( 'ct_tag_id', $this->changeTagDefStore->getId( $params['tag'] ) );
167 } catch ( NameTableAccessException ) {
168 // Return nothing.
169 $this->addWhere( '1=0' );
170 }
171 }
172
173 if ( $resultPageSet === null && $this->fetchContent ) {
174 // For each page we will request, the user must have read rights for that page
175 $status = Status::newGood();
176
178 foreach ( $pageSet->getGoodPages() as $pageIdentity ) {
179 if ( !$this->getAuthority()->authorizeRead( 'read', $pageIdentity ) ) {
180 $status->fatal( ApiMessage::create(
181 [
182 'apierror-cannotviewtitle',
183 wfEscapeWikiText( $this->titleFormatter->getPrefixedText( $pageIdentity ) ),
184 ],
185 'accessdenied'
186 ) );
187 }
188 }
189 if ( !$status->isGood() ) {
190 $this->dieStatus( $status );
191 }
192 }
193
194 if ( $enumRevMode ) {
195 // Indexes targeted:
196 // page_timestamp if we don't have rvuser
197 // page_actor_timestamp (on revision_actor_temp) if we have rvuser in READ_NEW mode
198 // page_user_timestamp if we have a logged-in rvuser
199 // page_timestamp or usertext_timestamp if we have an IP rvuser
200
201 // This is mostly to prevent parameter errors (and optimize SQL?)
202 $this->requireMaxOneParameter( $params, 'startid', 'start' );
203 $this->requireMaxOneParameter( $params, 'endid', 'end' );
204 $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
205
206 if ( $params['continue'] !== null ) {
207 $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'timestamp', 'int' ] );
208 $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
209 $continueTimestamp = $db->timestamp( $cont[0] );
210 $continueId = (int)$cont[1];
211 $this->addWhere( $db->buildComparison( $op, [
212 $tsField => $continueTimestamp,
213 $idField => $continueId,
214 ] ) );
215 }
216
217 // Convert startid/endid to timestamps (T163532)
218 $revids = [];
219 if ( $params['startid'] !== null ) {
220 $revids[] = (int)$params['startid'];
221 }
222 if ( $params['endid'] !== null ) {
223 $revids[] = (int)$params['endid'];
224 }
225 if ( $revids ) {
226 $db = $this->getDB();
227 $uqb = $db->newUnionQueryBuilder();
228 $uqb->add(
229 $db->newSelectQueryBuilder()
230 ->select( [ 'id' => 'rev_id', 'ts' => 'rev_timestamp' ] )
231 ->from( 'revision' )
232 ->where( [ 'rev_id' => $revids ] )
233 );
234 $uqb->add(
235 $db->newSelectQueryBuilder()
236 ->select( [ 'id' => 'ar_rev_id', 'ts' => 'ar_timestamp' ] )
237 ->from( 'archive' )
238 ->where( [ 'ar_rev_id' => $revids ] )
239 );
240 $res = $uqb->caller( __METHOD__ )->fetchResultSet();
241 foreach ( $res as $row ) {
242 if ( (int)$row->id === (int)$params['startid'] ) {
243 $params['start'] = $row->ts;
244 }
245 if ( (int)$row->id === (int)$params['endid'] ) {
246 $params['end'] = $row->ts;
247 }
248 }
249 if ( $params['startid'] !== null && $params['start'] === null ) {
250 $p = $this->encodeParamName( 'startid' );
251 $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
252 }
253 if ( $params['endid'] !== null && $params['end'] === null ) {
254 $p = $this->encodeParamName( 'endid' );
255 $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
256 }
257
258 if ( $params['start'] !== null ) {
259 $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
260 $ts = $db->timestampOrNull( $params['start'] );
261 if ( $params['startid'] !== null ) {
262 $this->addWhere( $db->buildComparison( $op, [
263 $tsField => $ts,
264 $idField => (int)$params['startid'],
265 ] ) );
266 } else {
267 $this->addWhere( $db->buildComparison( $op, [ $tsField => $ts ] ) );
268 }
269 }
270 if ( $params['end'] !== null ) {
271 $op = ( $params['dir'] === 'newer' ? '<=' : '>=' ); // Yes, opposite of the above
272 $ts = $db->timestampOrNull( $params['end'] );
273 if ( $params['endid'] !== null ) {
274 $this->addWhere( $db->buildComparison( $op, [
275 $tsField => $ts,
276 $idField => (int)$params['endid'],
277 ] ) );
278 } else {
279 $this->addWhere( $db->buildComparison( $op, [ $tsField => $ts ] ) );
280 }
281 }
282 } else {
283 $this->addTimestampWhereRange( $tsField, $params['dir'],
284 $params['start'], $params['end'] );
285 }
286
287 $sort = ( $params['dir'] === 'newer' ? '' : 'DESC' );
288 $this->addOption( 'ORDER BY', [ "rev_timestamp $sort", "rev_id $sort" ] );
289
290 // There is only one ID, use it
291 $ids = array_keys( $pageSet->getGoodPages() );
292 $this->addWhereFld( $pageField, reset( $ids ) );
293
294 if ( $params['user'] !== null ) {
295 $actorQuery = $this->actorMigration->getWhere( $db, 'rev_user', $params['user'] );
296 $this->addTables( $actorQuery['tables'] );
297 $this->addJoinConds( $actorQuery['joins'] );
298 $this->addWhere( $actorQuery['conds'] );
299 } elseif ( $params['excludeuser'] !== null ) {
300 $actorQuery = $this->actorMigration->getWhere( $db, 'rev_user', $params['excludeuser'] );
301 $this->addTables( $actorQuery['tables'] );
302 $this->addJoinConds( $actorQuery['joins'] );
303 $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' );
304 } else {
305 // T258480: MariaDB ends up using rev_page_actor_timestamp in some cases here.
306 // Last checked with MariaDB 10.4.13
307 // Unless we are filtering by user (see above), we always want to use the
308 // "history" index on the revision table, namely page_timestamp.
309 $useIndex['revision'] = 'rev_page_timestamp';
310 }
311
312 if ( $params['user'] !== null || $params['excludeuser'] !== null ) {
313 // Paranoia: avoid brute force searches (T19342)
314 if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
315 $bitmask = RevisionRecord::DELETED_USER;
316 } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
317 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
318 } else {
319 $bitmask = 0;
320 }
321 if ( $bitmask ) {
322 $this->addWhere( $db->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" );
323 }
324 }
325 } elseif ( $revCount > 0 ) {
326 // Always targets the PRIMARY index
327
328 $revs = $pageSet->getLiveRevisionIDs();
329
330 // Get all revision IDs
331 $this->addWhereFld( 'rev_id', array_keys( $revs ) );
332
333 if ( $params['continue'] !== null ) {
334 $this->addWhere( $db->buildComparison( '>=', [
335 'rev_id' => (int)$params['continue']
336 ] ) );
337 }
338 $this->addOption( 'ORDER BY', 'rev_id' );
339 } elseif ( $pageCount > 0 ) {
340 // Always targets the rev_page_id index
341
342 $pageids = array_keys( $pageSet->getGoodPages() );
343
344 // When working in multi-page non-enumeration mode,
345 // limit to the latest revision only
346 $this->addWhere( 'page_latest=rev_id' );
347
348 // Get all page IDs
349 $this->addWhereFld( 'page_id', $pageids );
350 // Every time someone relies on equality propagation, god kills a kitten :)
351 $this->addWhereFld( 'rev_page', $pageids );
352
353 if ( $params['continue'] !== null ) {
354 $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'int', 'int' ] );
355 $this->addWhere( $db->buildComparison( '>=', [
356 'rev_page' => $cont[0],
357 'rev_id' => $cont[1],
358 ] ) );
359 }
360 $this->addOption( 'ORDER BY', [
361 'rev_page',
362 'rev_id'
363 ] );
364 } else {
365 ApiBase::dieDebug( __METHOD__, 'param validation?' );
366 }
367
368 $this->addOption( 'LIMIT', $this->limit + 1 );
369
370 $this->addOption( 'IGNORE INDEX', $ignoreIndex );
371
372 if ( $useIndex ) {
373 $this->addOption( 'USE INDEX', $useIndex );
374 }
375
376 $count = 0;
377 $generated = [];
378 $hookData = [];
379 $res = $this->select( __METHOD__, [], $hookData );
380
381 foreach ( $res as $row ) {
382 if ( ++$count > $this->limit ) {
383 // We've reached the one extra which shows that there are
384 // additional pages to be had. Stop here...
385 if ( $enumRevMode ) {
386 $this->setContinueEnumParameter( 'continue',
387 $row->rev_timestamp . '|' . (int)$row->rev_id );
388 } elseif ( $revCount > 0 ) {
389 $this->setContinueEnumParameter( 'continue', (int)$row->rev_id );
390 } else {
391 $this->setContinueEnumParameter( 'continue', (int)$row->rev_page .
392 '|' . (int)$row->rev_id );
393 }
394 break;
395 }
396
397 if ( $resultPageSet !== null ) {
398 $generated[] = $row->rev_id;
399 } else {
400 $revision = $this->revisionStore->newRevisionFromRow( $row, 0, Title::newFromRow( $row ) );
401 $rev = $this->extractRevisionInfo( $revision, $row );
402 $fit = $this->processRow( $row, $rev, $hookData ) &&
403 $this->addPageSubItem( $row->rev_page, $rev, 'rev' );
404 if ( !$fit ) {
405 if ( $enumRevMode ) {
406 $this->setContinueEnumParameter( 'continue',
407 $row->rev_timestamp . '|' . (int)$row->rev_id );
408 } elseif ( $revCount > 0 ) {
409 $this->setContinueEnumParameter( 'continue', (int)$row->rev_id );
410 } else {
411 $this->setContinueEnumParameter( 'continue', (int)$row->rev_page .
412 '|' . (int)$row->rev_id );
413 }
414 break;
415 }
416 }
417 }
418
419 if ( $resultPageSet !== null ) {
420 $resultPageSet->populateFromRevisionIDs( $generated );
421 }
422 }
423
425 public function getAllowedParams() {
426 $ret = parent::getAllowedParams() + [
427 'startid' => [
428 ParamValidator::PARAM_TYPE => 'integer',
429 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
430 ],
431 'endid' => [
432 ParamValidator::PARAM_TYPE => 'integer',
433 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
434 ],
435 'start' => [
436 ParamValidator::PARAM_TYPE => 'timestamp',
437 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
438 ],
439 'end' => [
440 ParamValidator::PARAM_TYPE => 'timestamp',
441 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
442 ],
443 'dir' => [
444 ParamValidator::PARAM_DEFAULT => 'older',
445 ParamValidator::PARAM_TYPE => [
446 'newer',
447 'older'
448 ],
449 ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
451 'newer' => 'api-help-paramvalue-direction-newer',
452 'older' => 'api-help-paramvalue-direction-older',
453 ],
454 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
455 ],
456 'user' => [
457 ParamValidator::PARAM_TYPE => 'user',
458 UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
459 UserDef::PARAM_RETURN_OBJECT => true,
460 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
461 ],
462 'excludeuser' => [
463 ParamValidator::PARAM_TYPE => 'user',
464 UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
465 UserDef::PARAM_RETURN_OBJECT => true,
466 ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
467 ],
468 'tag' => null,
469 'continue' => [
470 ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
471 ],
472 ];
473
474 $ret['limit'][ApiBase::PARAM_HELP_MSG_INFO] = [ [ 'singlepageonly' ] ];
475
476 return $ret;
477 }
478
480 protected function getExamplesMessages() {
481 $title = Title::newMainPage()->getPrefixedText();
482 $mp = rawurlencode( $title );
483
484 return [
485 "action=query&prop=revisions&titles=API|{$mp}&" .
486 'rvslots=*&rvprop=timestamp|user|comment|content'
487 => 'apihelp-query+revisions-example-content',
488 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
489 'rvprop=timestamp|user|comment'
490 => 'apihelp-query+revisions-example-last5',
491 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
492 'rvprop=timestamp|user|comment&rvdir=newer'
493 => 'apihelp-query+revisions-example-first5',
494 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
495 'rvprop=timestamp|user|comment&rvdir=newer&rvstart=2006-05-01T00:00:00Z'
496 => 'apihelp-query+revisions-example-first5-after',
497 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
498 'rvprop=timestamp|user|comment&rvexcludeuser=127.0.0.1'
499 => 'apihelp-query+revisions-example-first5-not-localhost',
500 "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
501 'rvprop=timestamp|user|comment&rvuser=MediaWiki%20default'
502 => 'apihelp-query+revisions-example-first5-user',
503 ];
504 }
505
507 public function getHelpUrls() {
508 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Revisions';
509 }
510}
511
513class_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)